diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 8b7612d2..b0cefd4e 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -71,6 +71,9 @@ jobs: - crate: notifications packages: libglib2.0-dev libgdk-pixbuf-2.0-dev + - crate: applications + packages: "" + steps: - name: Checkout repository uses: actions/checkout@v4 @@ -81,11 +84,14 @@ jobs: - name: Rust cache uses: Swatinem/rust-cache@v2 - - if: matrix.packages != '' - name: System dependencies + - name: Install just run: | sudo apt-get update sudo apt-get install -y just + + - if: matrix.packages != '' + name: System dependencies + run: | sudo apt-get install -y ${{ matrix.packages }} - name: Run tests diff --git a/Cargo.lock b/Cargo.lock index c65283a2..899bf99e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "aligned" version = "0.4.3" @@ -32,6 +41,23 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "applications" +version = "0.1.0" +dependencies = [ + "configparser", + "fake", + "notify", + "nucleo-matcher", + "rand 0.10.2", + "tempfile", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -264,6 +290,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "configparser" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b46dec724fd22199ebde05033a0cbae453bc3b1ecff11eb6a6bb3eec4b90c6a4" + [[package]] name = "cpufeatures" version = "0.3.0" @@ -401,7 +433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -490,6 +522,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-channel" version = "0.3.33" @@ -651,7 +692,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -796,6 +837,26 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inotify" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -843,6 +904,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -912,6 +993,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -954,8 +1044,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", + "log", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1030,6 +1121,24 @@ dependencies = [ "tokio", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + [[package]] name = "notify-rust" version = "4.18.0" @@ -1046,6 +1155,34 @@ dependencies = [ "zbus", ] +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + +[[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 = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -1210,6 +1347,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "powerfmt" version = "0.2.0" @@ -1291,6 +1434,86 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "py_applications" +version = "0.1.0" +dependencies = [ + "applications", + "pyo3", + "pyo3-async-runtimes", +] + +[[package]] +name = "pyo3" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-async-runtimes" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ef68daa7316a3fac65e5e18b2203f010346de1c1c53456811a2624673ab046" +dependencies = [ + "futures-channel", + "futures-util", + "once_cell", + "pin-project-lite", + "pyo3", + "tokio", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "qoi" version = "0.4.1" @@ -1458,6 +1681,23 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" +[[package]] +name = "regex-automata" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + [[package]] name = "rgb" version = "0.8.53" @@ -1474,7 +1714,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1483,6 +1723,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "serde" version = "1.0.229" @@ -1546,6 +1795,15 @@ dependencies = [ "serde_core", ] +[[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 = "2.0.1" @@ -1596,7 +1854,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1667,7 +1925,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1690,6 +1948,15 @@ dependencies = [ "syn 3.0.2", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiff" version = "0.11.3" @@ -1737,7 +2004,7 @@ dependencies = [ "socket2", "tokio-macros", "tracing", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1831,6 +2098,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1841,7 +2138,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1850,6 +2147,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "uuid" version = "1.24.0" @@ -1873,6 +2176,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version-compare" version = "0.2.1" @@ -1885,6 +2194,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1951,6 +2270,15 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[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 = "windows" version = "0.61.3" @@ -2059,6 +2387,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2068,6 +2405,23 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -2086,6 +2440,54 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "1.0.4" @@ -2130,7 +2532,7 @@ dependencies = [ "tracing", "uds_windows", "uuid", - "windows-sys", + "windows-sys 0.61.2", "winnow", "zbus_macros", "zbus_names", diff --git a/Cargo.toml b/Cargo.toml index 7af64bb4..f91e5b88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [workspace] -members = ["crates/glib_utils", +members = ["crates/applications","crates/glib_utils", "crates/notifications", "crates/notifications_glib", + "crates/py_applications", ] resolver = "3" @@ -14,6 +15,7 @@ tokio = { version = "1.52.3", features = ["macros", "rt", "rt-multi-thread", "sy glib_utils = { path = "./crates/glib_utils" } tracing = "0.1.44" tracing-subscriber = "0.3.23" +thiserror = "2.0.17" [workspace.package] license = "MIT" diff --git a/crates/applications/Cargo.toml b/crates/applications/Cargo.toml new file mode 100644 index 00000000..10afaba6 --- /dev/null +++ b/crates/applications/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "applications" +version = "0.1.0" +edition = "2024" +license.workspace = true + +[dependencies] +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true +configparser = "3.2.0" +notify = "8.2.0" +nucleo-matcher = "0.3.1" + +[dev-dependencies] +tempfile = "3.27.0" +fake = "5.1.0" +rand = "0.10.2" +uuid = { version = "1.24.0", features = ["v4"] } +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } diff --git a/crates/applications/justfile b/crates/applications/justfile new file mode 100644 index 00000000..2713ad66 --- /dev/null +++ b/crates/applications/justfile @@ -0,0 +1,2 @@ +test: + cargo test -p applications diff --git a/crates/applications/src/action.rs b/crates/applications/src/action.rs new file mode 100644 index 00000000..14c96258 --- /dev/null +++ b/crates/applications/src/action.rs @@ -0,0 +1,135 @@ +use configparser::ini::Ini; + +use crate::private_prelude::*; + +pub(crate) struct Action { + id: String, + ini: Ini, + name: String, +} + +impl Action { + fn get_section(id: &str) -> String { + format!("Desktop Action {}", id) + } + + pub(crate) fn new(id: String, ini: Ini) -> Option { + Some(Self { + name: ini.get(&Self::get_section(&id), "Name")?, + id, + ini, + }) + } +} + +/// A handle which represents a desktop application action. +pub struct ActionHandle { + pub(crate) inner: Arc, + pub(crate) service: ApplicationService, +} + +impl ActionHandle { + fn get_section(&self) -> String { + Action::get_section(&self.inner.id) + } + + fn get_value(&self, key: &str) -> Option { + self.inner.ini.get(&self.get_section(), key) + } + + fn get_value_locale(&self, key: &str) -> Option { + utils::get_locale_string( + &self.inner.ini, + &self.get_section(), + key, + &self.service.inner.locale, + ) + } + + /// Returns the name of the action. + /// + /// For example: `Launch in new window`. + pub fn name(&self) -> String { + self.inner.name.clone() + } + + /// Returns the localized name of the action. + pub fn name_locale(&self) -> String { + self.get_value_locale("Name").unwrap_or_else(|| self.name()) + } + + /// Returns the icon of the action. + pub fn icon(&self) -> Option { + self.get_value("Icon") + } + + /// Returns the localized icon of the action. + pub fn icon_locale(&self) -> Option { + self.get_value_locale("Icon").or_else(|| self.icon()) + } + + /// Returns the exec string of the action. + pub fn exec(&self) -> Option { + self.get_value("Exec") + } + + fn terminal(&self) -> bool { + self.inner + .ini + .getbool("Desktop Entry", "Terminal") + .ok() + .flatten() + .unwrap_or(false) + } + + /// Launches the action. + pub fn launch(&self) -> Result<()> { + utils::launch_from_exec_string(self.exec(), self.terminal()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use configparser::ini::Ini; + + fn new_handle(id: &str, contents: String, locale: &str) -> ActionHandle { + let mut ini = Ini::new(); + ini.read(contents).unwrap(); + + ActionHandle { + inner: Arc::new(Action::new(String::from(id), ini).unwrap()), + service: ApplicationService::new_with_env(vec![], locale), + } + } + #[test] + fn test_properties() { + let contents = String::from( + r#"[Desktop Action Meow] +Name=Meow +Name[en]=Meow english +Icon=some-icon +Exec=ls"#, + ); + + let handle = new_handle("Meow", contents, "en"); + + assert_eq!(handle.name(), "Meow"); + assert_eq!(handle.name_locale(), "Meow english"); + assert_eq!(handle.icon().unwrap(), "some-icon"); + assert_eq!(handle.exec().unwrap(), "ls"); + } + + #[test] + #[should_panic] + fn test_invalid() { + // entry without name + let contents = String::from( + r#"[Desktop Action Meow] +Icon=some-icon +Exec=ls"#, + ); + + new_handle("Meow", contents, ""); + } +} diff --git a/crates/applications/src/desktopapp.rs b/crates/applications/src/desktopapp.rs new file mode 100644 index 00000000..7160084b --- /dev/null +++ b/crates/applications/src/desktopapp.rs @@ -0,0 +1,297 @@ +use crate::private_prelude::*; +use configparser::ini::Ini; + +fn string_to_vec(value: Option) -> Vec { + value + .map(|value| { + value + .split(";") + .filter(|s| !s.is_empty()) + .map(String::from) + .collect() + }) + .unwrap_or_default() +} +pub(crate) struct DesktopApp { + pub(crate) app_id: String, + pub(crate) ini: Ini, + pub(crate) actions: Vec>, + pub(crate) name: String, +} + +impl DesktopApp { + pub(crate) fn new(app_id: String, contents: String) -> Option { + let mut ini = Ini::new(); + ini.set_comment_symbols(&['#']); + + ini.read(contents).ok()?; + + if ini + .getbool("Desktop Entry", "NoDisplay") + .unwrap_or_else(|_| Some(false)) + .unwrap_or(false) + { + return None; + } + + if ini.get("Desktop Entry", "Type")? != "Application" { + return None; + }; + + let actions: Vec> = ini + .get("Desktop Entry", "Actions") + .and_then(|a| { + Some( + a.split(";") + .into_iter() + .filter(|a| !a.is_empty()) + .filter_map(|id| Action::new(String::from(id), ini.clone())) + .map(|action| Arc::new(action)) + .collect(), + ) + }) + .unwrap_or_else(|| Vec::new()); + + let name = ini.get("Desktop Entry", "Name")?; + + Some(Self { + app_id, + ini, + actions, + name, + }) + } +} + +/// A handle which represents a desktop application. +#[derive(Clone)] +pub struct DesktopAppHandle { + pub(crate) inner: Arc, + pub(crate) service: ApplicationService, +} + +impl DesktopAppHandle { + fn get_value(&self, key: &str) -> Option { + self.inner.ini.get("Desktop Entry", key) + } + + fn get_value_locale(&self, key: &str) -> Option { + // Locale matching with order: + // lang_COUNTRY@MODIFIER - lang_COUNTRY@MODIFIER, lang_COUNTRY, lang@MODIFIER, lang, default value + // lang_COUNTRY - lang_COUNTRY, lang, default value + // lang@MODIFIER - lang@MODIFIER, lang, default value + // lang - lang, default value + // + // See spec for more info: https://specifications.freedesktop.org/desktop-entry/latest/localized-keys.html + + utils::get_locale_string( + &self.inner.ini, + "Desktop Entry", + key, + &self.service.inner.locale, + ) + } + + /// Returns the unique ID of the application. + pub fn app_id(&self) -> String { + self.inner.app_id.clone() + } + + /// Returns the name of the application. + /// + /// For example: `firefox`. + pub fn name(&self) -> String { + self.inner.name.clone() + } + + /// Returns the localized name of the application. + pub fn name_locale(&self) -> String { + self.get_value_locale("Name").unwrap_or_else(|| self.name()) + } + + /// Returns the generic name of the application. + /// + /// For example: `Web browser`. + pub fn generic_name(&self) -> Option { + self.get_value("GenericName") + } + + /// Returns the localized generic name of the application. + pub fn generic_name_locale(&self) -> Option { + self.get_value_locale("GenericName") + } + + /// Returns the icon of the application. + /// + /// It's either the name of the icon or the absolute path. + pub fn icon(&self) -> Option { + self.get_value("Icon") + } + + /// Returns the localized icon of the application. + pub fn icon_locale(&self) -> Option { + self.get_value_locale("Icon") + } + + /// Returns a list of keywords describing the application. + pub fn keywords(&self) -> Vec { + string_to_vec(self.get_value("Keywords")) + } + + /// Returns a list of localized keywords describing the application. + pub fn keywords_locale(&self) -> Vec { + string_to_vec(self.get_value_locale("Keywords")) + } + + /// Returns the string containing the program to execute, possibly with arguments. + pub fn exec(&self) -> Option { + self.get_value("Exec") + } + + /// Returns whether the program should run in a terminal window. + pub fn terminal(&self) -> bool { + self.get_value("Terminal") + .and_then(|value| value.parse::().ok()) + .unwrap_or(false) + } + + /// Returns a list of application actions. Can be empty. + pub fn actions(&self) -> Vec { + self.inner + .actions + .iter() + .map(|action| ActionHandle { + inner: action.clone(), + service: self.service.clone(), + }) + .collect() + } + + /// Launches the application based on the [`exec()`] string. + /// + /// Starts a default terminal window if [`terminal()`] is `true`. + /// + /// The launched child process is detached from this process. + pub fn launch(&self) -> Result<()> { + utils::launch_from_exec_string(self.exec(), self.terminal()) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + fn new_handle(contents: String, locale: &str) -> DesktopAppHandle { + DesktopAppHandle { + inner: Arc::new( + DesktopApp::new(String::from("com.example.program"), contents).unwrap(), + ), + service: ApplicationService::new_with_env(Vec::new(), locale), + } + } + + #[test] + fn test_properties() { + let contents = format!( + r#"[Desktop Entry] +Name=Some Name +GenericName=This feels too generic +Icon=some-icon +Keywords=One;Two;Three; +Exec=do --this +Terminal=true +Type=Application +"# + ); + + let handle = new_handle(contents, ""); + assert_eq!(handle.name(), "Some Name"); + assert_eq!(handle.generic_name().unwrap(), "This feels too generic"); + assert_eq!(handle.icon().unwrap(), "some-icon"); + assert_eq!(handle.keywords(), vec!["One", "Two", "Three"]); + assert_eq!(handle.exec().unwrap(), "do --this"); + assert_eq!(handle.terminal(), true); + } + + #[test] + fn test_locale() { + let contents = String::from( + r#"[Desktop Entry] +Name=Some Name +Name[en_US@Idk]=Lang Country Modifier +Name[en_US]=Lang Country +Name[en@Idk]=Lang Modifier +Name[en]=Lang Only +Type=Application +"#, + ); + let handle = new_handle(contents.clone(), "en_US@Idk"); + + assert_eq!(handle.name_locale(), "Lang Country Modifier"); + + let handle = new_handle(contents.clone(), "en_US"); + assert_eq!(handle.name_locale(), "Lang Country"); + + let handle = new_handle(contents.clone(), "en@Idk"); + assert_eq!(handle.name_locale(), "Lang Modifier"); + + let handle = new_handle(contents.clone(), "en"); + assert_eq!(handle.name_locale(), "Lang Only"); + + let handle = new_handle(contents, "invalid"); + assert_eq!(handle.name_locale(), "Some Name"); + + let contents = String::from( + r#"[Desktop Entry] +Name=Some Name +Type=Application +Name[en_US]=Lang Country +Name[en@Idk]=Lang Modifier +Name[en]=Lang Only +"#, + ); + + let handle = new_handle(contents.clone(), "en_US@Idk"); + + assert_eq!(handle.name_locale(), "Lang Country"); + } + + #[test] + fn test_actions() { + let contents = String::from( + r#"[Desktop Entry] +Name=Some Name +Type=Application +Actions=Meow;Open;Test + +[Desktop Action Meow] +Name=Meow +Icon=some-icon +Exec=ls + +[Desktop Action Open] +Name=Meow +Icon=some-icon +Exec=ls +"#, + ); + + let handle = new_handle(contents, ""); + + assert_eq!(handle.actions().len(), 2); + } + + #[tokio::test] + async fn test_exec() { + let contents = String::from( + r#"[Desktop Entry] +Name=Some Name +Type=Application +Exec=ls +"#, + ); + let handle = new_handle(contents, ""); + handle.launch().unwrap(); + } +} diff --git a/crates/applications/src/error.rs b/crates/applications/src/error.rs new file mode 100644 index 00000000..19d612f7 --- /dev/null +++ b/crates/applications/src/error.rs @@ -0,0 +1,20 @@ +use thiserror::Error; + +/// Error enum +#[derive(Error, Debug)] +pub enum Error { + /// An error in inotify. + #[error("Notify error: {0}")] + NotifyError(#[from] notify::Error), + + /// Attempted to launch application/action but exec string is empty. + #[error("Exec string is empty")] + ExecEmpty, + + /// I/O error. + #[error("I/O Error: {0}")] + IOError(#[from] std::io::Error), +} + +/// Alias for a [`std::result::Result`] with the error type [`crate::Error`]. +pub type Result = std::result::Result; diff --git a/crates/applications/src/event.rs b/crates/applications/src/event.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/applications/src/event.rs @@ -0,0 +1 @@ + diff --git a/crates/applications/src/lib.rs b/crates/applications/src/lib.rs new file mode 100644 index 00000000..a2566bb5 --- /dev/null +++ b/crates/applications/src/lib.rs @@ -0,0 +1,48 @@ +//! # applications +//! +//! Access desktop application entries defined according to the [XDG Desktop Entry Specification](https://specifications.freedesktop.org/desktop-entry/latest). +//! +//! # Example +//! ```rust +//! use applications::ApplicationService; +//! +//! let service = ApplicationService::new(); +//! +//! // Display names of all apps +//! for i in service.apps() { +//! println!("{}", i.name()) +//! } +//! +//! // Fuzzy search by name +//! // Best matches come first in the vector +//! let matches = service.search_by_name("firox"); +//! +//! +//! if let Some(app) = matches.first() { +//! // Launch application +//! app.launch(); +//! +//! // Inspect actions +//! for action in app.actions() { +//! println!("{}", action.name()); +//! +//! // You can launch them too +//! // action.launch() +//! } +//! } +//! +//! ``` +#![warn(missing_docs)] +mod action; +mod desktopapp; +mod error; +mod event; +mod locale; +mod private_prelude; +mod service; +mod utils; + +pub use action::ActionHandle; +pub use desktopapp::DesktopAppHandle; +pub use error::{Error, Result}; +pub use service::ApplicationService; diff --git a/crates/applications/src/locale.rs b/crates/applications/src/locale.rs new file mode 100644 index 00000000..47ca9aa7 --- /dev/null +++ b/crates/applications/src/locale.rs @@ -0,0 +1,100 @@ +pub(crate) struct SystemLocale { + pub(crate) lang: String, + pub(crate) country: Option, + pub(crate) modifier: Option, +} + +impl SystemLocale { + pub(crate) fn new(input: &str) -> Self { + let (base, modifier) = input + .split_once('@') + .map_or((input, None), |(a, b)| (a, Some(b.to_string()))); + + let (base, _encoding) = base + .split_once('.') + .map_or((base, None), |(a, b)| (a, Some(b.to_string()))); + + let (lang, country) = base + .split_once('_') + .map_or((base, None), |(a, b)| (a, Some(b.to_string()))); + + Self { + lang: lang.into(), + country: country, + modifier: modifier, + } + } + + pub(crate) fn lang_country_modifier(&self) -> Option { + Some(format!( + "{}_{}@{}", + self.lang, + self.country.clone()?, + self.modifier.clone()? + )) + } + + pub(crate) fn lang_country(&self) -> Option { + Some(format!("{}_{}", self.lang, self.country.clone()?,)) + } + + pub(crate) fn lang_modifier(&self) -> Option { + Some(format!("{}@{}", self.lang, self.modifier.clone()?)) + } + + pub(crate) fn lang_only(&self) -> String { + self.lang.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lang_country_encoding() { + let locale = SystemLocale::new("en_US.UTF-8"); + assert_eq!(locale.lang, "en"); + assert_eq!(locale.country.unwrap(), "US"); + assert!(locale.modifier.is_none()); + } + + #[test] + fn test_lang_country_modifier() { + let locale = SystemLocale::new("en_US@Idk"); + assert_eq!(locale.lang, "en"); + assert_eq!(locale.country.unwrap(), "US"); + assert_eq!(locale.modifier.unwrap(), "Idk"); + } + + #[test] + fn test_lang_country() { + let locale = SystemLocale::new("en_US"); + assert_eq!(locale.lang, "en"); + assert_eq!(locale.country.unwrap(), "US"); + } + + #[test] + fn test_lang_modifier() { + let locale = SystemLocale::new("en@Idk"); + assert_eq!(locale.lang, "en"); + assert!(locale.country.is_none()); + assert_eq!(locale.modifier.unwrap(), "Idk"); + } + + #[test] + fn test_lang_only() { + let locale = SystemLocale::new("en"); + assert_eq!(locale.lang, "en"); + assert!(locale.country.is_none()); + assert!(locale.modifier.is_none()); + } + + #[test] + fn test_full() { + let locale = SystemLocale::new("en_US.UTF-8@Idk"); + assert_eq!(locale.lang, "en"); + assert_eq!(locale.country.unwrap(), "US"); + assert_eq!(locale.modifier.unwrap(), "Idk"); + } +} diff --git a/crates/applications/src/private_prelude.rs b/crates/applications/src/private_prelude.rs new file mode 100644 index 00000000..641117bb --- /dev/null +++ b/crates/applications/src/private_prelude.rs @@ -0,0 +1,10 @@ +pub(crate) use crate::action::Action; +pub(crate) use crate::desktopapp::DesktopApp; + +#[allow(unused_imports)] +pub(crate) use crate::locale::SystemLocale; +pub(crate) use crate::utils; + +#[allow(unused_imports)] +pub(crate) use crate::{ActionHandle, ApplicationService, DesktopAppHandle, Error, Result}; +pub(crate) use std::sync::Arc; diff --git a/crates/applications/src/service.rs b/crates/applications/src/service.rs new file mode 100644 index 00000000..6a4a8f26 --- /dev/null +++ b/crates/applications/src/service.rs @@ -0,0 +1,321 @@ +use crate::locale::SystemLocale; +use crate::private_prelude::*; +use notify::{EventKind, INotifyWatcher, RecursiveMode, Watcher}; +use nucleo_matcher::{Config, Matcher, Utf32Str}; +use std::sync::{Mutex, mpsc}; +use std::thread; +use std::{collections::HashMap, env, fs, path::PathBuf, sync::RwLock}; + +pub(crate) struct ApplicationServiceInner { + applications: RwLock>>, + watcher: RwLock>, + app_dirs: Vec, + pub(crate) locale: SystemLocale, + matcher: Mutex, +} + +/// A service to access desktop applications. +#[derive(Clone)] +pub struct ApplicationService { + pub(crate) inner: Arc, +} + +impl ApplicationService { + /// Creates a new instance. + /// + /// It loads all application entries from `XDG_DATA_DIRS` and gets the system locale. + pub fn new() -> Self { + Self::new_with_env( + env::var_os("XDG_DATA_DIRS") + .into_iter() + .flat_map(|value| env::split_paths(&value).collect::>()) + .map(|path| path.join("applications")) + .collect(), + ["LC_MESSAGES", "LC_ALL", "LANG"] + .iter() + .find_map(|name| env::var(name).ok()) + .unwrap_or_default() + .as_ref(), + ) + } + + pub(crate) fn new_with_env(app_dirs: Vec, locale_string: &str) -> Self { + Self { + inner: Arc::new(ApplicationServiceInner { + applications: RwLock::new(Self::init_apps(&app_dirs)), + watcher: RwLock::new(None), + app_dirs, + locale: SystemLocale::new(&locale_string), + matcher: Mutex::new(Matcher::new(Config::DEFAULT)), + }), + } + } + + /// Starts watching for changes in application entries. Re-initializes apps if a change occurs. + /// + /// # Errors + /// `Error::NotifyError` + pub fn watch(&self) -> Result<()> { + let (tx, rx) = mpsc::channel(); + + let mut watcher = notify::recommended_watcher(tx)?; + + for path in &self.inner.app_dirs { + watcher.watch(&path, RecursiveMode::NonRecursive)?; + } + + *self.inner.watcher.write().unwrap() = Some(watcher); + + let service = self.clone(); + + thread::spawn(move || { + fn refresh(service: ApplicationService) { + *service.inner.applications.write().unwrap() = + ApplicationService::init_apps(&service.inner.app_dirs); + + tracing::debug!("Apps are refreshed"); + } + + for res in rx { + let service = service.clone(); + match res { + Ok(event) => match event.kind { + // EventKind::Create(_) => refresh(service), + // EventKind::Remove(_) => refresh(service), + EventKind::Modify(_) => refresh(service), + _ => {} + }, + Err(e) => { + tracing::warn!("Watch error: {}", e) + } + } + } + }); + + Ok(()) + } + + fn init_apps(app_dirs: &Vec) -> HashMap> { + app_dirs + .into_iter() + .filter_map(|dir| fs::read_dir(dir).ok()) + .flatten() + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_name().to_string_lossy().ends_with(".desktop")) + .filter_map(|entry| { + DesktopApp::new( + entry.file_name().to_string_lossy().replace(".desktop", ""), + fs::read_to_string(entry.path()).ok()?, + ) + }) + .map(|app| (app.app_id.clone(), Arc::new(app))) + .fold(HashMap::new(), |mut map, (app_id, app)| { + // use the first appearance of the desktop file + map.entry(app_id).or_insert(app); + map + }) + } + + /// Returns a list of applications. + pub fn apps(&self) -> Vec { + self.inner + .applications + .read() + .unwrap() + .values() + .map(|app| DesktopAppHandle { + inner: app.clone(), + service: self.clone(), + }) + .collect() + } + + /// Returns an application by its ID, or `None` if it is not found. + pub fn app_by_id(&self, app_id: &str) -> Option { + self.inner + .applications + .read() + .unwrap() + .get(app_id) + .and_then(|app| { + Some(DesktopAppHandle { + inner: app.clone(), + service: self.clone(), + }) + }) + } + + /// Fuzzily search through the application entries by provided application name. + pub fn search_by_name(&self, query: &str) -> Vec { + let mut query_buf = Vec::new(); + let needle = Utf32Str::new(query, &mut query_buf); + + let mut results = Vec::new(); + + for app in self.apps() { + let mut app_buf = Vec::new(); + let name = app.name(); + let haystack = Utf32Str::new(&name, &mut app_buf); + + if let Some(score) = self + .inner + .matcher + .lock() + .unwrap() + .fuzzy_match(haystack, needle) + { + results.push((app, score)) + } + } + + results.sort_unstable_by_key(|(_, score)| std::cmp::Reverse(*score)); + + results.into_iter().map(|(handle, _)| handle).collect() + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tempfile::TempDir; + + use super::*; + use fake::Fake; + use fake::faker::lorem::en::Sentence; + use tracing_subscriber::EnvFilter; + use uuid::Uuid; + + struct TestContext { + tmp_dir: TempDir, + apps_dir: PathBuf, + } + + impl TestContext { + fn new() -> Self { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::new("debug")) + .try_init() + .ok(); + + let mut tmp_dir = TempDir::new().unwrap(); + + if std::env::var_os("NO_TMP_CLEANUP") + .unwrap_or_default() + .to_string_lossy() + == "1" + { + tmp_dir.disable_cleanup(true); + } + + let apps_dir = tmp_dir.path().to_owned().join("applications"); + std::fs::create_dir(&apps_dir).unwrap(); + + Self { tmp_dir, apps_dir } + } + + fn add_random_entry(&self, application_type: &str, no_display: bool) { + let name: String = Sentence(1..4).fake(); + let app_id = Uuid::new_v4().to_string(); + + let contents = format!( + r#"[Desktop Entry] +Name={} +Type={} +NoDisplay={} + "#, + name, application_type, no_display + ); + + std::fs::write(self.apps_dir.join(format!("{}.desktop", app_id)), contents).unwrap(); + } + + fn add_app_entry(&self, name: &str) { + let app_id = Uuid::new_v4().to_string(); + let contents = format!( + r#"[Desktop Entry] +Name={} +Type=Application + "#, + name + ); + + std::fs::write(self.apps_dir.join(format!("{}.desktop", app_id)), contents).unwrap(); + } + + fn init_service(&self) -> ApplicationService { + ApplicationService::new_with_env( + vec![self.tmp_dir.path().to_owned().join("applications")], + "", + ) + } + } + + #[test] + fn test_new() { + let ctx = TestContext::new(); + let service = ctx.init_service(); + + assert_eq!(service.apps().len(), 0); + } + + #[test] + fn test_apps() { + let ctx = TestContext::new(); + for _ in 0..10 { + ctx.add_random_entry("Application", false); + } + + for _ in 0..5 { + ctx.add_random_entry("Link", false); + } + + ctx.add_random_entry("Application", true); + + let service = ctx.init_service(); + + assert_eq!(service.apps().len(), 10); + } + + #[test] + fn test_watch() { + let ctx = TestContext::new(); + ctx.add_random_entry("Application", false); + let service = ctx.init_service(); + + assert_eq!(service.apps().len(), 1); + + service.watch().unwrap(); + ctx.add_random_entry("Application", false); + + thread::sleep(Duration::from_millis(500)); + assert_eq!(service.apps().len(), 2); + } + + #[test] + fn test_search() { + let ctx = TestContext::new(); + ctx.add_app_entry("Firefox"); + ctx.add_app_entry("Steam"); + ctx.add_app_entry("Ignis"); + + let service = ctx.init_service(); + + assert_eq!( + service.search_by_name("Firefox").get(0).unwrap().name(), + "Firefox" + ); + + assert_eq!( + service.search_by_name("fire").get(0).unwrap().name(), + "Firefox" + ); + + assert_eq!(service.search_by_name("sm").get(0).unwrap().name(), "Steam"); + + assert_eq!( + service.search_by_name("igns").get(0).unwrap().name(), + "Ignis" + ); + } +} diff --git a/crates/applications/src/utils.rs b/crates/applications/src/utils.rs new file mode 100644 index 00000000..319b214b --- /dev/null +++ b/crates/applications/src/utils.rs @@ -0,0 +1,69 @@ +use crate::{locale::SystemLocale, private_prelude::*}; +use configparser::ini::Ini; +use std::process::{Command, Stdio}; + +fn parse_exec(input: &str) -> Result<(String, Vec)> { + let args: Vec = input + .split(" ") + .map(String::from) + .filter(|s| !s.starts_with("%")) // TODO: support field codes + .collect(); + + let executable = args.get(0).ok_or(Error::ExecEmpty)?.to_owned(); + + Ok((executable, args[1..].to_owned())) +} + +pub(crate) fn launch_from_exec_string(exec_string: Option, terminal: bool) -> Result<()> { + let (executable, args) = parse_exec(&exec_string.ok_or(Error::ExecEmpty)?)?; + + let mut command = if terminal { + let mut command = Command::new("xdg-terminal-exec"); + command.arg("--").arg(executable).args(&args); + command + } else { + let mut command = Command::new(executable); + command.args(&args); + command + }; + + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + + Ok(()) +} + +pub(crate) fn get_locale_string( + ini: &Ini, + section: &str, + key: &str, + locale: &SystemLocale, +) -> Option { + let get_value = |locale: String| ini.get(section, &format!("{}[{}]", key, locale)); + + get_value(locale.lang_country_modifier().unwrap_or_default()) + .or_else(|| get_value(locale.lang_country().unwrap_or_default())) + .or_else(|| get_value(locale.lang_modifier().unwrap_or_default())) + .or_else(|| get_value(locale.lang_only())) + .or_else(|| ini.get(section, key)) +} + +#[cfg(test)] +mod tests { + use crate::utils::parse_exec; + + #[test] + fn test_parse_exec() { + let string = "my-bin -a --test %F %U"; + assert_eq!( + parse_exec(string).unwrap(), + ( + "my-bin".to_string(), + vec!["-a".to_string(), "--test".to_string()] + ) + ); + } +} diff --git a/crates/notifications/Cargo.toml b/crates/notifications/Cargo.toml index 8b936d77..28be26c3 100644 --- a/crates/notifications/Cargo.toml +++ b/crates/notifications/Cargo.toml @@ -11,10 +11,10 @@ gio.workspace = true tokio.workspace = true gdk-pixbuf.workspace = true tracing.workspace = true +thiserror.workspace = true serde = { version = "1.0.228", features = ["rc"] } serde_json = "1.0.149" -thiserror = "2.0.17" zbus = { version = "5.12.0", default-features = false, features = ["tokio"] } zvariant = { version = "5.8.0", features = ["arrayvec"] } diff --git a/crates/notifications_glib/default.nix b/crates/notifications_glib/default.nix index c416b67a..dc2bd6e4 100644 --- a/crates/notifications_glib/default.nix +++ b/crates/notifications_glib/default.nix @@ -21,7 +21,7 @@ stdenv.mkDerivation (finalAttrs: { cargoDeps = rustPlatform.fetchCargoVendor { inherit (finalAttrs) src; name = "ignis-notifications-${finalAttrs.version}"; - hash = "sha256-yzvK4JJJkOHH3/HPkqOTzwkXxv8vMnIMiB8xeWMyJ3o="; + hash = "sha256-yF88HTp8JNKs4+OKHCxJRJLLKmeoe7yDDmZbPg7Asmw="; }; nativeBuildInputs = [ diff --git a/crates/py_applications/.gitignore b/crates/py_applications/.gitignore new file mode 100644 index 00000000..c8f04429 --- /dev/null +++ b/crates/py_applications/.gitignore @@ -0,0 +1,72 @@ +/target + +# Byte-compiled / optimized / DLL files +__pycache__/ +.pytest_cache/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +.venv/ +env/ +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +include/ +man/ +venv/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +pip-selfcheck.json + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +.DS_Store + +# Sphinx documentation +docs/_build/ + +# PyCharm +.idea/ + +# VSCode +.vscode/ + +# Pyenv +.python-version diff --git a/crates/py_applications/Cargo.toml b/crates/py_applications/Cargo.toml new file mode 100644 index 00000000..f95d0edd --- /dev/null +++ b/crates/py_applications/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "py_applications" +version = "0.1.0" +edition = "2024" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +name = "ignis_applications" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = "0.29.0" +applications = { path = "../applications" } +pyo3-async-runtimes = { version = "0.29.0", features = ["tokio-runtime"] } diff --git a/crates/py_applications/default.nix b/crates/py_applications/default.nix new file mode 100644 index 00000000..6dd089e9 --- /dev/null +++ b/crates/py_applications/default.nix @@ -0,0 +1,25 @@ +{ + pythonPackages, + rustPlatform, +}: +pythonPackages.buildPythonPackage { + pname = "py-ignis-applications"; + version = "0.1.0"; + + src = ../..; + + pyproject = true; + + cargoDeps = rustPlatform.importCargoLock { + lockFile = ../../Cargo.lock; + }; + + nativeBuildInputs = with rustPlatform; [ + cargoSetupHook + maturinBuildHook + ]; + + buildAndTestSubdir = "crates/py_applications"; + + doCheck = false; +} diff --git a/crates/py_applications/pyproject.toml b/crates/py_applications/pyproject.toml new file mode 100644 index 00000000..ef55f0f6 --- /dev/null +++ b/crates/py_applications/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["maturin>=1.14,<2.0"] +build-backend = "maturin" + +[project] +name = "ignis_applications" +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dynamic = ["version"] diff --git a/crates/py_applications/src/lib.rs b/crates/py_applications/src/lib.rs new file mode 100644 index 00000000..196339bd --- /dev/null +++ b/crates/py_applications/src/lib.rs @@ -0,0 +1,216 @@ +use pyo3::prelude::*; + +#[pymodule] +mod ignis_applications { + use pyo3::prelude::*; + + use applications::{ + ActionHandle, ApplicationService as RApplicationService, DesktopAppHandle, Error as RError, + }; + use pyo3::exceptions::{PyOSError, PyValueError}; + + fn to_py_err(e: RError) -> PyErr { + let msg = e.to_string(); + + match e { + RError::NotifyError(_) => PyOSError::new_err(msg), + RError::IOError(_) => PyOSError::new_err(msg), + RError::ExecEmpty => PyValueError::new_err(msg), + } + } + + /// A desktop application action. + #[pyclass] + struct Action { + inner: ActionHandle, + } + + #[pymethods] + impl Action { + /// Launches the action. + fn launch(&self) -> PyResult<()> { + self.inner.launch().map_err(to_py_err) + } + + /// The name of the action. + /// + /// For example: `Launch in new window`. + #[getter] + fn name(&self) -> String { + self.inner.name() + } + + /// The localized name of the action. + #[getter] + fn name_locale(&self) -> String { + self.inner.name_locale() + } + + /// The icon of the action. + #[getter] + fn icon(&self) -> Option { + self.inner.icon() + } + + /// The localized icon of the action. + #[getter] + fn icon_locale(&self) -> Option { + self.inner.icon_locale() + } + + /// The exec string of the action. + #[getter] + fn exec(&self) -> Option { + self.inner.exec() + } + } + + /// A desktop application. + #[pyclass] + struct DesktopApp { + inner: DesktopAppHandle, + } + + #[pymethods] + impl DesktopApp { + /// Launches the application based on the [`exec()`] string. + /// + /// Starts a default terminal window if [`terminal()`] is `true`. + /// + /// The launched child process is detached from this process. + fn launch(&self) -> PyResult<()> { + self.inner.launch().map_err(to_py_err) + } + + /// The unique ID of the application. + #[getter] + pub fn app_id(&self) -> String { + self.inner.app_id() + } + + /// The name of the application. + /// + /// For example: `firefox`. + #[getter] + fn name(&self) -> String { + self.inner.name() + } + + /// The localized name of the application. + #[getter] + pub fn name_locale(&self) -> String { + self.inner.name_locale() + } + + /// The generic name of the application. + /// + /// For example: `Web browser`. + #[getter] + pub fn generic_name(&self) -> Option { + self.inner.generic_name() + } + + /// The localized generic name of the application. + #[getter] + pub fn generic_name_locale(&self) -> Option { + self.inner.generic_name_locale() + } + + /// The icon of the application. + /// + /// It's either the name of the icon or the absolute path. + #[getter] + pub fn icon(&self) -> Option { + self.inner.icon() + } + + /// The localized icon of the application. + #[getter] + pub fn icon_locale(&self) -> Option { + self.inner.icon_locale() + } + + /// A list of keywords describing the application. + #[getter] + pub fn keywords(&self) -> Vec { + self.inner.keywords() + } + + /// A list of localized keywords describing the application. + #[getter] + pub fn keywords_locale(&self) -> Vec { + self.inner.keywords_locale() + } + + /// The string containing the program to execute, possibly with arguments. + #[getter] + pub fn exec(&self) -> Option { + self.inner.exec() + } + + /// Whether the program should run in a terminal window. + #[getter] + pub fn terminal(&self) -> bool { + self.inner.terminal() + } + + /// A list of application actions. Can be empty. + #[getter] + pub fn actions(&self) -> Vec { + self.inner + .actions() + .into_iter() + .map(|handle| Action { inner: handle }) + .collect() + } + } + + /// A service to access desktop applications. + /// It loads all application entries from `XDG_DATA_DIRS` and gets the system locale. + #[pyclass] + struct ApplicationService { + inner: RApplicationService, + } + + #[pymethods] + impl ApplicationService { + #[new] + fn new() -> Self { + Self { + inner: RApplicationService::new(), + } + } + + /// Starts watching for changes in application entries. Re-initializes apps if a change occurs. + fn watch(&self) -> PyResult<()> { + self.inner.watch().map_err(to_py_err)?; + Ok(()) + } + + /// A list of applications. + #[getter] + fn apps(&self) -> Vec { + self.inner + .apps() + .into_iter() + .map(|handle| DesktopApp { inner: handle }) + .collect() + } + + /// An application by its ID, or `None` if it is not found. + fn get_app_by_id(&self, app_id: &str) -> Option { + Some(DesktopApp { + inner: self.inner.app_by_id(app_id)?, + }) + } + + /// Fuzzily search through the application entries by provided application name. + fn search_by_name(&self, query: &str) -> Vec { + self.inner + .search_by_name(query) + .into_iter() + .map(|handle| DesktopApp { inner: handle }) + .collect() + } + } +} diff --git a/flake.nix b/flake.nix index 7b7089c0..3a9e8500 100644 --- a/flake.nix +++ b/flake.nix @@ -29,6 +29,11 @@ overlays = [ rust-overlay.overlays.default ]; + + mkPythonPkg = pkgs: pythonPackages: path: + pkgs.callPackage path { + inherit pythonPackages; + }; in { packages = forAllSystems (system: let pkgs = import nixpkgs { @@ -42,6 +47,18 @@ default = self.packages.${system}.ignis; ignis-notifications-glib = pkgs.callPackage ./crates/notifications_glib {}; + + python313Packages.ignis-applications = + mkPythonPkg + pkgs + pkgs.python313Packages + ./crates/py_applications; + + python314Packages.ignis-applications = + mkPythonPkg + pkgs + pkgs.python314Packages + ./crates/py_applications; }); formatter = forAllSystems (system: nixpkgs.legacyPackages.${system}.alejandra); diff --git a/nix/devshell.nix b/nix/devshell.nix index c15fa88d..aa49da9a 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -34,6 +34,7 @@ python313Packages.pygobject3 just gi-docgen + maturin ]; in { default = pkgs.mkShell {