Compare commits
70 Commits
owlry-rune
...
owlry-v1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| f189f4b1ce | |||
| 422ea6d816 | |||
| 8b444eec3b | |||
| 6d0bf1c401 | |||
| c8d8298274 | |||
| 62f6e1d4b0 | |||
| bf1d759cb2 | |||
| 3f9f4bb112 | |||
| c5f1f35167 | |||
| 81626c33dd | |||
| 99d38a66b8 | |||
| 8b4c704501 | |||
| 27e296e333 | |||
| 173d72ad43 | |||
| 3eea902c7f | |||
| a12e850c94 | |||
| eccfb217d4 | |||
| c3c35611fd | |||
| 5ecd0a6412 | |||
| 6fe7213b6f | |||
| b768bfd181 | |||
| c9a1ff28f4 | |||
| 623572ec14 | |||
| 5196255594 | |||
| b87447156e | |||
| 12d554959a | |||
| 83fa22d84c | |||
| ade5d3aeef | |||
| 617c943147 | |||
| 1b1e12124b | |||
| 94556f1fe0 | |||
| 2b98f0651c | |||
| 75fa770c94 | |||
| c6ba91f06d | |||
| 235103e854 | |||
| 8ccaaf28c8 | |||
| cfd143fe4a | |||
| 10a685c62f | |||
| 34db33c75f | |||
| 4bff83b5e6 | |||
| 8f7501038d | |||
| 4032205800 | |||
| 99985c7f3b | |||
| 6113217f7b | |||
| 558d415e12 | |||
| 6bde1504b1 | |||
| ffe04f3c54 | |||
| 5c0e63f94c | |||
| 5441011d6b | |||
| 317572634f | |||
| 449dc010db | |||
| 7273cd3ba7 | |||
| f8388a4327 | |||
| fa671ebd77 | |||
| d63c7d170b | |||
| 5f14ed2b3b | |||
| 83f551dd7f | |||
| 9b1eada1ee | |||
| 677e6d7fa9 | |||
| f0741f4128 | |||
| 7da8f3c249 | |||
| 38dda8c44c | |||
| ab2d3cfe55 | |||
| e2939e266c | |||
| 651166a9f3 | |||
| a2eb7d1b0d | |||
| 8073d27df2 | |||
| 3349350bf6 | |||
| 3aaeafde8b | |||
| 7ce6de17aa |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,8 +1,10 @@
|
||||
/target
|
||||
CLAUDE.md
|
||||
.worktrees/
|
||||
media.md
|
||||
|
||||
# AUR packages (each is its own git repo for aur.archlinux.org)
|
||||
# Track PKGBUILD and .SRCINFO, ignore build artifacts and sub-repo .git
|
||||
aur/*/.git/
|
||||
aur/*/pkg/
|
||||
aur/*/src/
|
||||
@@ -10,6 +12,3 @@ aur/*/*.tar.zst
|
||||
aur/*/*.tar.gz
|
||||
aur/*/*.tar.xz
|
||||
aur/*/*.pkg.tar.*
|
||||
# Keep PKGBUILD and .SRCINFO tracked
|
||||
.SRCINFO
|
||||
aur/
|
||||
|
||||
28
CLAUDE.md
28
CLAUDE.md
@@ -166,6 +166,23 @@ just bump 0.5.1
|
||||
# Create and push release tag
|
||||
git push && just tag
|
||||
|
||||
# Tagging convention: every crate gets its own tag
|
||||
# Format: {crate-name}-v{version}
|
||||
# Examples:
|
||||
# owlry-v1.0.1
|
||||
# owlry-core-v1.1.0
|
||||
# owlry-lua-v1.1.0
|
||||
# owlry-rune-v1.1.0
|
||||
# plugin-api-v1.0.1
|
||||
#
|
||||
# The plugins repo uses the same convention:
|
||||
# owlry-plugin-bookmarks-v1.0.1
|
||||
# owlry-plugin-calculator-v1.0.1
|
||||
# etc.
|
||||
#
|
||||
# IMPORTANT: After bumping versions, tag EVERY changed crate individually.
|
||||
# The plugin-api tag is referenced by owlry-plugins Cargo.toml as a git dependency.
|
||||
|
||||
# AUR package management
|
||||
just aur-update # Update core UI PKGBUILD
|
||||
just aur-update-pkg NAME # Update specific package (owlry-core, owlry-lua, etc.)
|
||||
@@ -299,10 +316,18 @@ CoreClient ──── IPC ────→ ProviderManager ProviderFi
|
||||
|
||||
All other providers are native plugins in the separate `owlry-plugins` repo (`somegit.dev/Owlibou/owlry-plugins`).
|
||||
|
||||
**User plugins** (script-based, in `~/.config/owlry/plugins/`):
|
||||
- **Lua plugins**: Loaded by `owlry-lua` runtime from `/usr/lib/owlry/runtimes/liblua.so`
|
||||
- **Rune plugins**: Loaded by `owlry-rune` runtime from `/usr/lib/owlry/runtimes/librune.so`
|
||||
- User plugins are **hot-reloaded** automatically when files change (no daemon restart needed)
|
||||
- Custom prefixes (e.g., `:hs`) are resolved dynamically for user plugins
|
||||
|
||||
`ProviderManager` (in `owlry-core`) orchestrates providers and handles:
|
||||
- Fuzzy matching via `SkimMatcherV2`
|
||||
- Frecency score boosting
|
||||
- Native plugin loading from `/usr/lib/owlry/plugins/`
|
||||
- Script runtime loading from `/usr/lib/owlry/runtimes/` for user plugins
|
||||
- Filesystem watching for automatic user plugin hot-reload
|
||||
|
||||
**Submenu System**: Plugins can return items with `SUBMENU:plugin_id:data` commands. When selected, the plugin is queried with `?SUBMENU:data` to get action items (e.g., systemd service actions).
|
||||
|
||||
@@ -340,6 +365,7 @@ Plugins are compiled as `.so` (cdylib) and loaded by the daemon at startup.
|
||||
- Profile-based mode selection (`--profile dev`)
|
||||
- Provider toggling (Ctrl+1/2/3)
|
||||
- Prefix parsing (`:app`, `:cmd`, `:sys`, etc.)
|
||||
- Dynamic prefix fallback for user plugins (any `:word` prefix maps to `Plugin(word)`)
|
||||
|
||||
Query parsing extracts prefix and forwards clean query to providers.
|
||||
|
||||
@@ -395,6 +421,8 @@ Plugins live in a separate repository: `somegit.dev/Owlibou/owlry-plugins`
|
||||
- **Rc<RefCell<T>>** used throughout for GTK signal handlers needing mutable state
|
||||
- **Feature flag `dev-logging`**: Wraps debug!() calls in `#[cfg(feature = "dev-logging")]`
|
||||
- **Feature flag `lua`**: Enables built-in Lua runtime (off by default); enable to embed Lua in core binary
|
||||
- **Script runtimes**: External `.so` runtimes loaded from `/usr/lib/owlry/runtimes/` — Lua and Rune user plugins loaded from `~/.config/owlry/plugins/`
|
||||
- **Hot-reload**: Filesystem watcher (`notify` crate) monitors user plugins dir and reloads runtimes on file changes
|
||||
- **dmenu mode**: Runs locally without daemon. Use `-m dmenu` with piped stdin
|
||||
- **Frecency**: Time-decayed frequency scoring stored in `~/.local/share/owlry/frecency.json`
|
||||
- **ABI stability**: Plugin interface uses `abi_stable` crate for safe Rust dynamic linking
|
||||
|
||||
179
Cargo.lock
generated
179
Cargo.lock
generated
@@ -337,6 +337,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
@@ -400,7 +406,7 @@ version = "0.21.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b01fe135c0bd16afe262b6dea349bd5ea30e6de50708cec639aae7c5c14cc7e4"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"cairo-sys-rs",
|
||||
"glib 0.21.5",
|
||||
"libc",
|
||||
@@ -699,7 +705,7 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
@@ -851,6 +857,17 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"libredox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -909,6 +926,15 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[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.32"
|
||||
@@ -1231,7 +1257,7 @@ version = "0.20.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
@@ -1252,7 +1278,7 @@ version = "0.21.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
@@ -1425,7 +1451,7 @@ version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1d422cce9367945916b7a5083eedf67b0a5380d326af1943a0b5cef9afb6e48"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"gdk4",
|
||||
"glib 0.21.5",
|
||||
"glib-sys 0.21.5",
|
||||
@@ -1777,6 +1803,35 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify-sys"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instant"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
@@ -1899,6 +1954,26 @@ version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue-sys"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -1943,7 +2018,10 @@ version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2102,6 +2180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -2186,7 +2265,7 @@ version = "0.31.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -2198,6 +2277,37 @@ version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce"
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "7.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"filetime",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio",
|
||||
"notify-types",
|
||||
"walkdir",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-debouncer-mini"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aaa5a66d07ed97dce782be94dcf5ab4d1b457f4243f7566c7557f15cabc8c799"
|
||||
dependencies = [
|
||||
"log",
|
||||
"notify",
|
||||
"notify-types",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.12.0"
|
||||
@@ -2212,6 +2322,15 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-types"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174"
|
||||
dependencies = [
|
||||
"instant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -2335,7 +2454,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
]
|
||||
@@ -2352,7 +2471,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
@@ -2417,12 +2536,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "owlry"
|
||||
version = "1.0.0"
|
||||
version = "1.0.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs",
|
||||
"env_logger",
|
||||
"futures-channel",
|
||||
"glib-build-tools",
|
||||
"gtk4",
|
||||
"gtk4-layer-shell",
|
||||
@@ -2437,7 +2557,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "owlry-core"
|
||||
version = "1.0.0"
|
||||
version = "1.2.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"ctrlc",
|
||||
@@ -2449,6 +2569,8 @@ dependencies = [
|
||||
"log",
|
||||
"meval",
|
||||
"mlua",
|
||||
"notify",
|
||||
"notify-debouncer-mini",
|
||||
"notify-rust",
|
||||
"owlry-plugin-api",
|
||||
"reqwest 0.13.2",
|
||||
@@ -2462,7 +2584,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "owlry-lua"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"abi_stable",
|
||||
"chrono",
|
||||
@@ -2480,7 +2602,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "owlry-plugin-api"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"abi_stable",
|
||||
"serde",
|
||||
@@ -2488,7 +2610,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "owlry-rune"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs",
|
||||
@@ -2553,7 +2675,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
@@ -2619,6 +2741,12 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
@@ -2821,7 +2949,16 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3096,7 +3233,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@@ -3227,7 +3364,7 @@ version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
@@ -3475,7 +3612,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
@@ -3803,7 +3940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
@@ -4161,7 +4298,7 @@ version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
@@ -4786,7 +4923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
|
||||
70
README.md
70
README.md
@@ -13,7 +13,8 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
|
||||
- **Client/daemon architecture** — Instant window appearance, providers stay loaded in memory
|
||||
- **Modular plugin architecture** — Install only what you need
|
||||
- **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags
|
||||
- **13 native plugins** — Calculator, clipboard, emoji, weather, media, and more
|
||||
- **Built-in calculator, converter, and system actions** — Works out of the box
|
||||
- **11 optional plugins** — Clipboard, emoji, weather, media, and more
|
||||
- **Widget providers** — Weather, media controls, and pomodoro timer at the top of results
|
||||
- **Config profiles** — Named mode presets for different workflows
|
||||
- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:tag:development`, etc.
|
||||
@@ -28,17 +29,11 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
|
||||
### Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
# Minimal core (applications + commands only)
|
||||
# Core (includes calculator, converter, system actions)
|
||||
yay -S owlry
|
||||
|
||||
# Add individual plugins
|
||||
yay -S owlry-plugin-calculator owlry-plugin-weather
|
||||
|
||||
# Or install bundles:
|
||||
yay -S owlry-meta-essentials # calculator, system, ssh, scripts, bookmarks
|
||||
yay -S owlry-meta-widgets # weather, media, pomodoro
|
||||
yay -S owlry-meta-tools # clipboard, emoji, websearch, filesearch, systemd
|
||||
yay -S owlry-meta-full # everything
|
||||
# Add individual plugins as needed
|
||||
yay -S owlry-plugin-bookmarks owlry-plugin-weather owlry-plugin-clipboard
|
||||
|
||||
# For custom Lua/Rune plugins
|
||||
yay -S owlry-lua # Lua 5.4 runtime
|
||||
@@ -47,22 +42,32 @@ yay -S owlry-rune # Rune runtime
|
||||
|
||||
### Available Packages
|
||||
|
||||
**Core packages** (this repo):
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `owlry` | Core: UI client (`owlry`) and daemon (`owlry-core`) |
|
||||
| `owlry-plugin-calculator` | Math expressions (`= 5+3`) |
|
||||
| `owlry-plugin-system` | Shutdown, reboot, suspend, lock |
|
||||
| `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` |
|
||||
| `owlry` | GTK4 UI client |
|
||||
| `owlry-core` | Headless daemon with built-in calculator, converter, and system providers |
|
||||
| `owlry-lua` | Lua 5.4 script runtime for user plugins |
|
||||
| `owlry-rune` | Rune script runtime for user plugins |
|
||||
|
||||
**Plugin packages** ([owlry-plugins](https://somegit.dev/Owlibou/owlry-plugins) repo):
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `owlry-plugin-bookmarks` | Firefox, Chrome, Brave, Edge bookmarks |
|
||||
| `owlry-plugin-clipboard` | History via cliphist |
|
||||
| `owlry-plugin-emoji` | 400+ searchable emoji |
|
||||
| `owlry-plugin-scripts` | User scripts |
|
||||
| `owlry-plugin-bookmarks` | Firefox, 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 |
|
||||
| `owlry-plugin-scripts` | User scripts |
|
||||
| `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` |
|
||||
| `owlry-plugin-systemd` | User services with actions |
|
||||
| `owlry-plugin-weather` | Weather widget |
|
||||
| `owlry-plugin-websearch` | Web search (`? query`) |
|
||||
|
||||
> **Note:** Calculator, converter, and system actions are built into `owlry-core` and no longer require separate plugin packages.
|
||||
|
||||
### Build from Source
|
||||
|
||||
@@ -83,22 +88,29 @@ sudo dnf install gtk4-devel gtk4-layer-shell-devel
|
||||
git clone https://somegit.dev/Owlibou/owlry.git
|
||||
cd owlry
|
||||
|
||||
# Build core only (daemon + UI)
|
||||
# Build daemon + UI
|
||||
cargo build --release -p owlry -p owlry-core
|
||||
|
||||
# Build specific plugin
|
||||
cargo build --release -p owlry-plugin-calculator
|
||||
# Build runtimes (for user plugins)
|
||||
cargo build --release -p owlry-lua -p owlry-rune
|
||||
|
||||
# Build everything
|
||||
# Build everything in this workspace
|
||||
cargo build --release --workspace
|
||||
```
|
||||
|
||||
**Plugins** are in a [separate repo](https://somegit.dev/Owlibou/owlry-plugins):
|
||||
```bash
|
||||
git clone https://somegit.dev/Owlibou/owlry-plugins.git
|
||||
cd owlry-plugins
|
||||
cargo build --release -p owlry-plugin-calculator # or any plugin
|
||||
```
|
||||
|
||||
**Install locally:**
|
||||
```bash
|
||||
just install-local
|
||||
```
|
||||
|
||||
This installs both binaries, all plugins, runtimes, and the systemd service files.
|
||||
This installs the UI, daemon, runtimes, and systemd service files.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -114,22 +126,22 @@ Add to your compositor config:
|
||||
|
||||
```bash
|
||||
# Hyprland (~/.config/hypr/hyprland.conf)
|
||||
exec-once = owlry-core
|
||||
exec-once = owlryd
|
||||
|
||||
# Sway (~/.config/sway/config)
|
||||
exec owlry-core
|
||||
exec owlryd
|
||||
```
|
||||
|
||||
**2. Systemd user service**
|
||||
|
||||
```bash
|
||||
systemctl --user enable --now owlry-core.service
|
||||
systemctl --user enable --now owlryd.service
|
||||
```
|
||||
|
||||
**3. Socket activation (auto-start on first use)**
|
||||
|
||||
```bash
|
||||
systemctl --user enable owlry-core.socket
|
||||
systemctl --user enable owlryd.socket
|
||||
```
|
||||
|
||||
The daemon starts automatically when the UI client first connects. No manual startup needed.
|
||||
@@ -457,8 +469,6 @@ owlry-core (daemon) owlry (GTK4 UI client)
|
||||
|
||||
The daemon keeps providers and plugins loaded in memory, so the UI appears instantly when launched. The UI client is a thin GTK4 layer that sends queries and receives results over the socket.
|
||||
|
||||
For detailed architecture information, see [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## License
|
||||
|
||||
GNU General Public License v3.0 — see [LICENSE](LICENSE).
|
||||
|
||||
13
aur/owlry-core/.SRCINFO
Normal file
13
aur/owlry-core/.SRCINFO
Normal file
@@ -0,0 +1,13 @@
|
||||
pkgbase = owlry-core
|
||||
pkgdesc = Core daemon for the Owlry application launcher — manages plugins, providers, and search
|
||||
pkgver = 1.2.0
|
||||
pkgrel = 1
|
||||
url = https://somegit.dev/Owlibou/owlry
|
||||
arch = x86_64
|
||||
license = GPL-3.0-or-later
|
||||
makedepends = cargo
|
||||
depends = gcc-libs
|
||||
source = owlry-core-1.2.0.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v1.2.0.tar.gz
|
||||
b2sums = 5e23b41ad12e3e0577213059e2509a9b42e3081b17944e300831e4cfa216628d5190e64d9fd72edc3aa34aebb387d3821ae1d9edd157acf1abf2e5b81f778fd7
|
||||
|
||||
pkgname = owlry-core
|
||||
41
aur/owlry-core/PKGBUILD
Normal file
41
aur/owlry-core/PKGBUILD
Normal file
@@ -0,0 +1,41 @@
|
||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||
pkgname=owlry-core
|
||||
pkgver=1.2.0
|
||||
pkgrel=1
|
||||
pkgdesc='Core daemon for the Owlry application launcher — manages plugins, providers, and search'
|
||||
arch=('x86_64')
|
||||
url='https://somegit.dev/Owlibou/owlry'
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=('gcc-libs')
|
||||
makedepends=('cargo')
|
||||
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v$pkgver.tar.gz")
|
||||
b2sums=('5e23b41ad12e3e0577213059e2509a9b42e3081b17944e300831e4cfa216628d5190e64d9fd72edc3aa34aebb387d3821ae1d9edd157acf1abf2e5b81f778fd7')
|
||||
|
||||
prepare() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo build -p owlry-core --frozen --release
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo test -p owlry-core --frozen --lib
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "owlry"
|
||||
install -Dm755 "target/release/owlryd" "$pkgdir/usr/bin/owlryd"
|
||||
install -Dm644 "systemd/owlryd.service" "$pkgdir/usr/lib/systemd/user/owlryd.service"
|
||||
install -Dm644 "systemd/owlryd.socket" "$pkgdir/usr/lib/systemd/user/owlryd.socket"
|
||||
install -dm755 "$pkgdir/usr/lib/owlry/plugins"
|
||||
install -dm755 "$pkgdir/usr/lib/owlry/runtimes"
|
||||
}
|
||||
13
aur/owlry-lua/.SRCINFO
Normal file
13
aur/owlry-lua/.SRCINFO
Normal file
@@ -0,0 +1,13 @@
|
||||
pkgbase = owlry-lua
|
||||
pkgdesc = Lua scripting runtime for Owlry — enables user-created Lua plugins
|
||||
pkgver = 1.1.0
|
||||
pkgrel = 1
|
||||
url = https://somegit.dev/Owlibou/owlry
|
||||
arch = x86_64
|
||||
license = GPL-3.0-or-later
|
||||
makedepends = cargo
|
||||
depends = owlry-core
|
||||
source = owlry-lua-1.1.0.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v1.1.0.tar.gz
|
||||
b2sums = d4b200446a31301b1240fd8eede6e10764d7bbc551f2e5549bfdbdcc0fa4a717677c3c2c69778d2dfa336711ac5b74d4987e46082ea589fed961c9d2ff95af76
|
||||
|
||||
pkgname = owlry-lua
|
||||
40
aur/owlry-lua/PKGBUILD
Normal file
40
aur/owlry-lua/PKGBUILD
Normal file
@@ -0,0 +1,40 @@
|
||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||
pkgname=owlry-lua
|
||||
pkgver=1.1.0
|
||||
pkgrel=1
|
||||
pkgdesc="Lua scripting runtime for Owlry — enables user-created Lua plugins"
|
||||
arch=('x86_64')
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=('owlry-core')
|
||||
makedepends=('cargo')
|
||||
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v$pkgver.tar.gz")
|
||||
b2sums=('d4b200446a31301b1240fd8eede6e10764d7bbc551f2e5549bfdbdcc0fa4a717677c3c2c69778d2dfa336711ac5b74d4987e46082ea589fed961c9d2ff95af76')
|
||||
|
||||
_cratename=owlry-lua
|
||||
|
||||
prepare() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo build -p $_cratename --frozen --release
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo test -p $_cratename --frozen --release
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "owlry"
|
||||
install -Dm755 "target/release/lib${_cratename//-/_}.so" \
|
||||
"$pkgdir/usr/lib/owlry/runtimes/liblua.so"
|
||||
}
|
||||
13
aur/owlry-rune/.SRCINFO
Normal file
13
aur/owlry-rune/.SRCINFO
Normal file
@@ -0,0 +1,13 @@
|
||||
pkgbase = owlry-rune
|
||||
pkgdesc = Rune scripting runtime for Owlry — enables user-created Rune plugins
|
||||
pkgver = 1.1.0
|
||||
pkgrel = 1
|
||||
url = https://somegit.dev/Owlibou/owlry
|
||||
arch = x86_64
|
||||
license = GPL-3.0-or-later
|
||||
makedepends = cargo
|
||||
depends = owlry-core
|
||||
source = owlry-rune-1.1.0.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v1.1.0.tar.gz
|
||||
b2sums = d4b200446a31301b1240fd8eede6e10764d7bbc551f2e5549bfdbdcc0fa4a717677c3c2c69778d2dfa336711ac5b74d4987e46082ea589fed961c9d2ff95af76
|
||||
|
||||
pkgname = owlry-rune
|
||||
40
aur/owlry-rune/PKGBUILD
Normal file
40
aur/owlry-rune/PKGBUILD
Normal file
@@ -0,0 +1,40 @@
|
||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||
pkgname=owlry-rune
|
||||
pkgver=1.1.0
|
||||
pkgrel=1
|
||||
pkgdesc="Rune scripting runtime for Owlry — enables user-created Rune plugins"
|
||||
arch=('x86_64')
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=('owlry-core')
|
||||
makedepends=('cargo')
|
||||
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v$pkgver.tar.gz")
|
||||
b2sums=('d4b200446a31301b1240fd8eede6e10764d7bbc551f2e5549bfdbdcc0fa4a717677c3c2c69778d2dfa336711ac5b74d4987e46082ea589fed961c9d2ff95af76')
|
||||
|
||||
_cratename=owlry-rune
|
||||
|
||||
prepare() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo build -p $_cratename --frozen --release
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo test -p $_cratename --frozen --release
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "owlry"
|
||||
install -Dm755 "target/release/lib${_cratename//-/_}.so" \
|
||||
"$pkgdir/usr/lib/owlry/runtimes/librune.so"
|
||||
}
|
||||
34
aur/owlry/.SRCINFO
Normal file
34
aur/owlry/.SRCINFO
Normal file
@@ -0,0 +1,34 @@
|
||||
pkgbase = owlry
|
||||
pkgdesc = Lightweight Wayland application launcher with plugin support
|
||||
pkgver = 1.0.5
|
||||
pkgrel = 1
|
||||
url = https://somegit.dev/Owlibou/owlry
|
||||
arch = x86_64
|
||||
license = GPL-3.0-or-later
|
||||
makedepends = cargo
|
||||
depends = owlry-core
|
||||
depends = gcc-libs
|
||||
depends = gtk4
|
||||
depends = gtk4-layer-shell
|
||||
optdepends = cliphist: clipboard provider support
|
||||
optdepends = wl-clipboard: clipboard and emoji copy support
|
||||
optdepends = fd: fast file search
|
||||
optdepends = owlry-plugin-calculator: calculator provider
|
||||
optdepends = owlry-plugin-clipboard: clipboard provider
|
||||
optdepends = owlry-plugin-emoji: emoji picker
|
||||
optdepends = owlry-plugin-bookmarks: browser bookmarks
|
||||
optdepends = owlry-plugin-ssh: SSH host launcher
|
||||
optdepends = owlry-plugin-scripts: custom scripts provider
|
||||
optdepends = owlry-plugin-system: system actions (shutdown, reboot, etc.)
|
||||
optdepends = owlry-plugin-websearch: web search provider
|
||||
optdepends = owlry-plugin-filesearch: file search provider
|
||||
optdepends = owlry-plugin-systemd: systemd service management
|
||||
optdepends = owlry-plugin-weather: weather widget
|
||||
optdepends = owlry-plugin-media: media player controls
|
||||
optdepends = owlry-plugin-pomodoro: pomodoro timer widget
|
||||
optdepends = owlry-lua: Lua runtime for user plugins
|
||||
optdepends = owlry-rune: Rune runtime for user plugins
|
||||
source = owlry-1.0.5.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v1.0.5.tar.gz
|
||||
b2sums = 3f7b9cde30a06d96f8c1fda1be72514ac5b0e835402c1a287bfd2d8ea92284874b5fcccfcd08d249eb7f28a3b5be6a3b77c495e610fe9742a6c7b3d5084c9894
|
||||
|
||||
pkgname = owlry
|
||||
76
aur/owlry/PKGBUILD
Normal file
76
aur/owlry/PKGBUILD
Normal file
@@ -0,0 +1,76 @@
|
||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||
pkgname=owlry
|
||||
pkgver=1.0.5
|
||||
pkgrel=1
|
||||
pkgdesc="Lightweight Wayland application launcher with plugin support"
|
||||
arch=('x86_64')
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=('owlry-core' 'gcc-libs' 'gtk4' 'gtk4-layer-shell')
|
||||
makedepends=('cargo')
|
||||
optdepends=(
|
||||
'cliphist: clipboard provider support'
|
||||
'wl-clipboard: clipboard and emoji copy support'
|
||||
'fd: fast file search'
|
||||
'owlry-plugin-calculator: calculator provider'
|
||||
'owlry-plugin-clipboard: clipboard provider'
|
||||
'owlry-plugin-emoji: emoji picker'
|
||||
'owlry-plugin-bookmarks: browser bookmarks'
|
||||
'owlry-plugin-ssh: SSH host launcher'
|
||||
'owlry-plugin-scripts: custom scripts provider'
|
||||
'owlry-plugin-system: system actions (shutdown, reboot, etc.)'
|
||||
'owlry-plugin-websearch: web search provider'
|
||||
'owlry-plugin-filesearch: file search provider'
|
||||
'owlry-plugin-systemd: systemd service management'
|
||||
'owlry-plugin-weather: weather widget'
|
||||
'owlry-plugin-media: media player controls'
|
||||
'owlry-plugin-pomodoro: pomodoro timer widget'
|
||||
'owlry-lua: Lua runtime for user plugins'
|
||||
'owlry-rune: Rune runtime for user plugins'
|
||||
)
|
||||
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v$pkgver.tar.gz")
|
||||
b2sums=('3f7b9cde30a06d96f8c1fda1be72514ac5b0e835402c1a287bfd2d8ea92284874b5fcccfcd08d249eb7f28a3b5be6a3b77c495e610fe9742a6c7b3d5084c9894')
|
||||
|
||||
prepare() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
# Build only the core binary without embedded Lua (Lua runtime is separate package)
|
||||
cargo build -p owlry --frozen --release --no-default-features
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "owlry"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo test -p owlry --frozen --no-default-features
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "owlry"
|
||||
|
||||
# Core binary
|
||||
install -Dm755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname"
|
||||
|
||||
# Documentation
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
|
||||
# Example configuration files
|
||||
install -Dm644 data/config.example.toml "$pkgdir/usr/share/doc/$pkgname/config.example.toml"
|
||||
install -Dm644 data/style.example.css "$pkgdir/usr/share/doc/$pkgname/style.example.css"
|
||||
install -Dm755 data/scripts/example.sh "$pkgdir/usr/share/doc/$pkgname/scripts/example.sh"
|
||||
|
||||
# Install themes
|
||||
install -d "$pkgdir/usr/share/$pkgname/themes"
|
||||
install -Dm644 data/themes/*.css "$pkgdir/usr/share/$pkgname/themes/"
|
||||
|
||||
# Example plugins (for user plugin development)
|
||||
install -d "$pkgdir/usr/share/$pkgname/examples/plugins"
|
||||
cp -r examples/plugins/* "$pkgdir/usr/share/$pkgname/examples/plugins/"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owlry-core"
|
||||
version = "1.0.0"
|
||||
version = "1.2.1"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -12,7 +12,7 @@ name = "owlry_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "owlry-core"
|
||||
name = "owlryd"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
@@ -36,6 +36,10 @@ dirs = "5"
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
|
||||
# Filesystem watching (plugin hot-reload)
|
||||
notify = "7"
|
||||
notify-debouncer-mini = "0.5"
|
||||
|
||||
# Signal handling
|
||||
ctrlc = { version = "3", features = ["termination"] }
|
||||
|
||||
@@ -44,15 +48,17 @@ log = "0.4"
|
||||
env_logger = "0.11"
|
||||
notify-rust = "4"
|
||||
|
||||
# Built-in providers
|
||||
meval = "0.2"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"] }
|
||||
|
||||
# Optional: embedded Lua runtime
|
||||
mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true }
|
||||
meval = { version = "0.2", optional = true }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
lua = ["dep:mlua", "dep:meval", "dep:reqwest"]
|
||||
lua = ["dep:mlua"]
|
||||
dev-logging = []
|
||||
|
||||
@@ -2,7 +2,6 @@ use log::{debug, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
@@ -163,6 +162,9 @@ pub struct ProvidersConfig {
|
||||
/// Enable calculator provider (= expression or calc expression)
|
||||
#[serde(default = "default_true")]
|
||||
pub calculator: bool,
|
||||
/// Enable converter provider (> expression or auto-detect)
|
||||
#[serde(default = "default_true")]
|
||||
pub converter: bool,
|
||||
/// Enable frecency-based result ranking
|
||||
#[serde(default = "default_true")]
|
||||
pub frecency: bool,
|
||||
@@ -240,6 +242,7 @@ impl Default for ProvidersConfig {
|
||||
commands: true,
|
||||
uuctl: true,
|
||||
calculator: true,
|
||||
converter: true,
|
||||
frecency: true,
|
||||
frecency_weight: 0.3,
|
||||
websearch: true,
|
||||
@@ -522,12 +525,15 @@ fn detect_de_terminal() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if a command exists in PATH
|
||||
/// Check if a command exists in PATH (in-process, no subprocess spawning)
|
||||
fn command_exists(cmd: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(cmd)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
std::env::var_os("PATH")
|
||||
.map(|paths| {
|
||||
std::env::split_paths(&paths).any(|dir| {
|
||||
let full = dir.join(cmd);
|
||||
full.is_file()
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -591,3 +597,17 @@ impl Config {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn command_exists_finds_sh() {
|
||||
// /bin/sh exists on every Unix system
|
||||
assert!(super::command_exists("sh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_exists_rejects_nonexistent() {
|
||||
assert!(!super::command_exists("owlry_nonexistent_binary_abc123"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,23 +131,36 @@ impl FrecencyStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate frecency score using a pre-sampled timestamp.
|
||||
/// Use this in hot loops to avoid repeated Utc::now() syscalls.
|
||||
pub fn get_score_at(&self, item_id: &str, now: DateTime<Utc>) -> f64 {
|
||||
match self.data.entries.get(item_id) {
|
||||
Some(entry) => Self::calculate_frecency_at(entry.launch_count, entry.last_launch, now),
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate frecency using Firefox-style algorithm
|
||||
fn calculate_frecency(launch_count: u32, last_launch: DateTime<Utc>) -> f64 {
|
||||
let now = Utc::now();
|
||||
Self::calculate_frecency_at(launch_count, last_launch, now)
|
||||
}
|
||||
|
||||
/// Calculate frecency using a caller-provided timestamp.
|
||||
fn calculate_frecency_at(launch_count: u32, last_launch: DateTime<Utc>, now: DateTime<Utc>) -> f64 {
|
||||
let age = now.signed_duration_since(last_launch);
|
||||
let age_days = age.num_hours() as f64 / 24.0;
|
||||
|
||||
// Recency weight based on how recently the item was used
|
||||
let recency_weight = if age_days < 1.0 {
|
||||
100.0 // Today
|
||||
100.0
|
||||
} else if age_days < 7.0 {
|
||||
70.0 // This week
|
||||
70.0
|
||||
} else if age_days < 30.0 {
|
||||
50.0 // This month
|
||||
50.0
|
||||
} else if age_days < 90.0 {
|
||||
30.0 // This quarter
|
||||
30.0
|
||||
} else {
|
||||
10.0 // Older
|
||||
10.0
|
||||
};
|
||||
|
||||
launch_count as f64 * recency_weight
|
||||
@@ -206,6 +219,32 @@ mod tests {
|
||||
assert!(score_month < score_week);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_score_at_matches_get_score() {
|
||||
let mut store = FrecencyStore {
|
||||
data: FrecencyData {
|
||||
version: 1,
|
||||
entries: HashMap::new(),
|
||||
},
|
||||
path: PathBuf::from("/dev/null"),
|
||||
dirty: false,
|
||||
};
|
||||
store.data.entries.insert(
|
||||
"test".to_string(),
|
||||
FrecencyEntry {
|
||||
launch_count: 5,
|
||||
last_launch: Utc::now(),
|
||||
},
|
||||
);
|
||||
|
||||
let now = Utc::now();
|
||||
let score_at = store.get_score_at("test", now);
|
||||
let score = store.get_score("test");
|
||||
|
||||
// Both should be very close (same timestamp, within rounding)
|
||||
assert!((score_at - score).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_launch_count_matters() {
|
||||
let now = Utc::now();
|
||||
|
||||
@@ -32,6 +32,8 @@ impl ProviderFilter {
|
||||
cli_providers: Option<Vec<ProviderType>>,
|
||||
config_providers: &ProvidersConfig,
|
||||
) -> Self {
|
||||
let accept_all = cli_mode.is_none() && cli_providers.is_none();
|
||||
|
||||
let enabled = if let Some(mode) = cli_mode {
|
||||
// --mode overrides everything: single provider
|
||||
HashSet::from([mode])
|
||||
@@ -90,7 +92,7 @@ impl ProviderFilter {
|
||||
let filter = Self {
|
||||
enabled,
|
||||
active_prefix: None,
|
||||
accept_all: false,
|
||||
accept_all,
|
||||
};
|
||||
|
||||
#[cfg(feature = "dev-logging")]
|
||||
@@ -184,6 +186,11 @@ impl ProviderFilter {
|
||||
self.accept_all || self.enabled.contains(&provider)
|
||||
}
|
||||
|
||||
/// Whether this filter accepts all provider types
|
||||
pub fn is_accept_all(&self) -> bool {
|
||||
self.accept_all
|
||||
}
|
||||
|
||||
/// Get current active prefix if any
|
||||
#[allow(dead_code)]
|
||||
pub fn active_prefix(&self) -> Option<ProviderType> {
|
||||
@@ -353,6 +360,28 @@ impl ProviderFilter {
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic plugin prefix fallback: ":word " or ":word" where word is unknown
|
||||
// Maps to Plugin(word) so user plugins with custom prefixes work
|
||||
if let Some(rest) = trimmed.strip_prefix(':') {
|
||||
if let Some(space_idx) = rest.find(' ') {
|
||||
let prefix_word = &rest[..space_idx];
|
||||
if !prefix_word.is_empty() && prefix_word.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
||||
return ParsedQuery {
|
||||
prefix: Some(ProviderType::Plugin(prefix_word.to_string())),
|
||||
tag_filter: None,
|
||||
query: rest[space_idx + 1..].to_string(),
|
||||
};
|
||||
}
|
||||
} else if !rest.is_empty() && rest.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
||||
// Partial prefix (no space yet)
|
||||
return ParsedQuery {
|
||||
prefix: Some(ProviderType::Plugin(rest.to_string())),
|
||||
tag_filter: None,
|
||||
query: String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let result = ParsedQuery {
|
||||
prefix: None,
|
||||
tag_filter: None,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use log::info;
|
||||
use log::{info, warn};
|
||||
|
||||
use owlry_core::paths;
|
||||
use owlry_core::server::Server;
|
||||
@@ -7,7 +7,7 @@ fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
|
||||
|
||||
let sock = paths::socket_path();
|
||||
info!("Starting owlry-core daemon...");
|
||||
info!("Starting owlryd daemon...");
|
||||
|
||||
// Ensure the socket parent directory exists
|
||||
if let Err(e) = paths::ensure_parent_dir(&sock) {
|
||||
@@ -18,18 +18,19 @@ fn main() {
|
||||
let server = match Server::bind(&sock) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to start owlry-core: {e}");
|
||||
eprintln!("Failed to start owlryd: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Graceful shutdown on SIGTERM/SIGINT
|
||||
let sock_cleanup = sock.clone();
|
||||
ctrlc::set_handler(move || {
|
||||
if let Err(e) = ctrlc::set_handler(move || {
|
||||
let _ = std::fs::remove_file(&sock_cleanup);
|
||||
std::process::exit(0);
|
||||
})
|
||||
.ok();
|
||||
}) {
|
||||
warn!("Failed to set signal handler: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = server.run() {
|
||||
eprintln!("Server error: {e}");
|
||||
|
||||
@@ -26,6 +26,7 @@ pub mod manifest;
|
||||
pub mod native_loader;
|
||||
pub mod registry;
|
||||
pub mod runtime_loader;
|
||||
pub mod watcher;
|
||||
|
||||
// Lua-specific modules (require mlua)
|
||||
#[cfg(feature = "lua")]
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
//! 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;
|
||||
|
||||
@@ -56,7 +54,7 @@ pub struct RuntimeHandle(pub *mut ());
|
||||
#[repr(C)]
|
||||
pub struct ScriptRuntimeVTable {
|
||||
pub info: extern "C" fn() -> RuntimeInfo,
|
||||
pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle,
|
||||
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
|
||||
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<ScriptProviderInfo>,
|
||||
pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
|
||||
pub query: extern "C" fn(
|
||||
@@ -83,12 +81,13 @@ pub struct LoadedRuntime {
|
||||
|
||||
impl LoadedRuntime {
|
||||
/// Load the Lua runtime from the system directory
|
||||
pub fn load_lua(plugins_dir: &Path) -> PluginResult<Self> {
|
||||
pub fn load_lua(plugins_dir: &Path, owlry_version: &str) -> PluginResult<Self> {
|
||||
Self::load_from_path(
|
||||
"Lua",
|
||||
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so"),
|
||||
b"owlry_lua_runtime_vtable",
|
||||
plugins_dir,
|
||||
owlry_version,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -98,6 +97,7 @@ impl LoadedRuntime {
|
||||
library_path: &Path,
|
||||
vtable_symbol: &[u8],
|
||||
plugins_dir: &Path,
|
||||
owlry_version: &str,
|
||||
) -> PluginResult<Self> {
|
||||
if !library_path.exists() {
|
||||
return Err(PluginError::NotFound(library_path.display().to_string()));
|
||||
@@ -124,7 +124,7 @@ impl LoadedRuntime {
|
||||
|
||||
// Initialize the runtime
|
||||
let plugins_dir_str = plugins_dir.to_string_lossy();
|
||||
let handle = (vtable.init)(RStr::from_str(&plugins_dir_str));
|
||||
let handle = (vtable.init)(RStr::from_str(&plugins_dir_str), RStr::from_str(owlry_version));
|
||||
|
||||
// Get provider information
|
||||
let providers_rvec = (vtable.providers)(handle);
|
||||
@@ -169,6 +169,14 @@ impl Drop for LoadedRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
// LoadedRuntime needs to be Send + Sync because ProviderManager is shared across
|
||||
// threads via Arc<RwLock<ProviderManager>>.
|
||||
// Safety: RuntimeHandle is an opaque FFI handle accessed only through extern "C"
|
||||
// vtable functions. The same safety argument that applies to RuntimeProvider applies
|
||||
// here — all access is mediated by the vtable, and the runtime itself serializes access.
|
||||
unsafe impl Send for LoadedRuntime {}
|
||||
unsafe impl Sync for LoadedRuntime {}
|
||||
|
||||
/// A provider backed by a dynamically loaded runtime
|
||||
pub struct RuntimeProvider {
|
||||
/// Runtime name (for logging)
|
||||
@@ -243,8 +251,12 @@ impl Provider for RuntimeProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// RuntimeProvider needs to be Send for the Provider trait
|
||||
// RuntimeProvider needs to be Send + Sync for the Provider trait.
|
||||
// Safety: RuntimeHandle is an opaque FFI handle accessed only through
|
||||
// extern "C" vtable functions. The same safety argument that justifies
|
||||
// Send applies to Sync — all access is mediated by the vtable.
|
||||
unsafe impl Send for RuntimeProvider {}
|
||||
unsafe impl Sync for RuntimeProvider {}
|
||||
|
||||
/// Check if the Lua runtime is available
|
||||
pub fn lua_runtime_available() -> bool {
|
||||
@@ -262,12 +274,13 @@ pub fn rune_runtime_available() -> bool {
|
||||
|
||||
impl LoadedRuntime {
|
||||
/// Load the Rune runtime from the system directory
|
||||
pub fn load_rune(plugins_dir: &Path) -> PluginResult<Self> {
|
||||
pub fn load_rune(plugins_dir: &Path, owlry_version: &str) -> PluginResult<Self> {
|
||||
Self::load_from_path(
|
||||
"Rune",
|
||||
&PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so"),
|
||||
b"owlry_rune_runtime_vtable",
|
||||
plugins_dir,
|
||||
owlry_version,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
104
crates/owlry-core/src/plugins/watcher.rs
Normal file
104
crates/owlry-core/src/plugins/watcher.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
//! Filesystem watcher for user plugin hot-reload
|
||||
//!
|
||||
//! Watches `~/.config/owlry/plugins/` for changes and triggers
|
||||
//! runtime reload when plugin files are modified.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use log::{info, warn};
|
||||
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
|
||||
|
||||
use crate::providers::ProviderManager;
|
||||
|
||||
/// Start watching the user plugins directory for changes.
|
||||
///
|
||||
/// Spawns a background thread that monitors the directory and triggers
|
||||
/// a full runtime reload on any file change. Returns immediately.
|
||||
///
|
||||
/// Respects `OWLRY_SKIP_RUNTIMES=1` — returns early if set.
|
||||
pub fn start_watching(pm: Arc<RwLock<ProviderManager>>) {
|
||||
if std::env::var("OWLRY_SKIP_RUNTIMES").is_ok() {
|
||||
info!("OWLRY_SKIP_RUNTIMES set, skipping file watcher");
|
||||
return;
|
||||
}
|
||||
|
||||
let plugins_dir = match crate::paths::plugins_dir() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
info!("No plugins directory configured, skipping file watcher");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !plugins_dir.exists()
|
||||
&& std::fs::create_dir_all(&plugins_dir).is_err()
|
||||
{
|
||||
warn!(
|
||||
"Failed to create plugins directory: {}",
|
||||
plugins_dir.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Plugin file watcher started for {}",
|
||||
plugins_dir.display()
|
||||
);
|
||||
|
||||
thread::spawn(move || {
|
||||
if let Err(e) = watch_loop(&plugins_dir, &pm) {
|
||||
warn!("Plugin watcher stopped: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn watch_loop(
|
||||
plugins_dir: &PathBuf,
|
||||
pm: &Arc<RwLock<ProviderManager>>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
let mut debouncer = new_debouncer(Duration::from_millis(500), tx)?;
|
||||
|
||||
debouncer
|
||||
.watcher()
|
||||
.watch(plugins_dir.as_ref(), notify::RecursiveMode::Recursive)?;
|
||||
|
||||
info!("Watching {} for plugin changes", plugins_dir.display());
|
||||
|
||||
// Skip events during initial startup grace period (watcher setup triggers events)
|
||||
let startup = std::time::Instant::now();
|
||||
let grace_period = Duration::from_secs(2);
|
||||
|
||||
loop {
|
||||
match rx.recv() {
|
||||
Ok(Ok(events)) => {
|
||||
if startup.elapsed() < grace_period {
|
||||
continue;
|
||||
}
|
||||
|
||||
let has_relevant_change = events.iter().any(|e| {
|
||||
matches!(
|
||||
e.kind,
|
||||
DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous
|
||||
)
|
||||
});
|
||||
|
||||
if has_relevant_change {
|
||||
info!("Plugin file change detected, reloading runtimes...");
|
||||
let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner());
|
||||
pm_guard.reload_runtimes();
|
||||
}
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
warn!("File watcher error: {}", error);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(Box::new(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
237
crates/owlry-core/src/providers/calculator.rs
Normal file
237
crates/owlry-core/src/providers/calculator.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
use super::{DynamicProvider, LaunchItem, ProviderType};
|
||||
|
||||
/// Built-in calculator provider. Evaluates mathematical expressions via `meval`.
|
||||
///
|
||||
/// Triggered by:
|
||||
/// - `= expr` / `=expr` / `calc expr` (explicit prefix)
|
||||
/// - Raw math expressions containing operators or known functions (auto-detect)
|
||||
pub(crate) struct CalculatorProvider;
|
||||
|
||||
impl DynamicProvider for CalculatorProvider {
|
||||
fn name(&self) -> &str {
|
||||
"Calculator"
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Plugin("calc".into())
|
||||
}
|
||||
|
||||
fn priority(&self) -> u32 {
|
||||
10_000
|
||||
}
|
||||
|
||||
fn query(&self, query: &str) -> Vec<LaunchItem> {
|
||||
let expr = match extract_expression(query) {
|
||||
Some(e) if !e.is_empty() => e,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
|
||||
match meval::eval_str(expr) {
|
||||
Ok(result) => {
|
||||
let display = format_result(result);
|
||||
let copy_cmd = format!(
|
||||
"printf '%s' '{}' | wl-copy",
|
||||
display.replace('\'', "'\\''")
|
||||
);
|
||||
vec![LaunchItem {
|
||||
id: format!("calc:{}", expr),
|
||||
name: display.clone(),
|
||||
description: Some(format!("= {}", expr)),
|
||||
icon: Some("accessories-calculator".into()),
|
||||
provider: ProviderType::Plugin("calc".into()),
|
||||
command: copy_cmd,
|
||||
terminal: false,
|
||||
tags: vec!["math".into(), "calculator".into()],
|
||||
}]
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the math expression from a query string.
|
||||
///
|
||||
/// Handles:
|
||||
/// - `= expr` and `=expr` (explicit calculator prefix)
|
||||
/// - `calc expr` (word prefix)
|
||||
/// - Raw expressions if they look like math (auto-detect)
|
||||
///
|
||||
/// Returns `None` only when input is empty after trimming.
|
||||
fn extract_expression(query: &str) -> Option<&str> {
|
||||
let trimmed = query.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Explicit prefixes
|
||||
if let Some(rest) = trimmed.strip_prefix("= ") {
|
||||
return Some(rest.trim());
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix('=') {
|
||||
return Some(rest.trim());
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("calc ") {
|
||||
return Some(rest.trim());
|
||||
}
|
||||
|
||||
// Auto-detect: only forward if the expression looks like math.
|
||||
// Plain words like "firefox" should not reach meval.
|
||||
if looks_like_math(trimmed) {
|
||||
Some(trimmed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Heuristic: does this string look like a math expression?
|
||||
///
|
||||
/// Returns true when the string contains binary operators, digits mixed with
|
||||
/// operators, or known function names. Plain alphabetic words return false.
|
||||
fn looks_like_math(s: &str) -> bool {
|
||||
// Must contain at least one digit or a known constant/function name
|
||||
let has_digit = s.chars().any(|c| c.is_ascii_digit());
|
||||
let has_operator = s.contains('+')
|
||||
|| s.contains('*')
|
||||
|| s.contains('/')
|
||||
|| s.contains('^')
|
||||
|| s.contains('%');
|
||||
// Subtraction/negation is ambiguous; only count it as an operator when
|
||||
// there are already digits present to avoid matching bare words with hyphens.
|
||||
let has_minus_operator = has_digit && s.contains('-');
|
||||
|
||||
// Known math functions that are safe to auto-evaluate
|
||||
const MATH_FUNCTIONS: &[&str] = &[
|
||||
"sqrt", "sin", "cos", "tan", "log", "ln", "abs", "floor", "ceil", "round",
|
||||
];
|
||||
let has_function = MATH_FUNCTIONS.iter().any(|f| s.contains(f));
|
||||
|
||||
has_digit && (has_operator || has_minus_operator) || has_function
|
||||
}
|
||||
|
||||
/// Format a floating-point result for display.
|
||||
///
|
||||
/// Integer-valued results are shown as integers with thousands separators.
|
||||
/// Non-integer results are shown with up to 10 decimal places, trailing zeros trimmed.
|
||||
fn format_result(result: f64) -> String {
|
||||
if result.fract() == 0.0 && result.abs() < 1e15 {
|
||||
format_integer_with_separators(result as i64)
|
||||
} else {
|
||||
let formatted = format!("{:.10}", result);
|
||||
formatted
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_integer_with_separators(n: i64) -> String {
|
||||
let s = n.unsigned_abs().to_string();
|
||||
let with_commas = s
|
||||
.as_bytes()
|
||||
.rchunks(3)
|
||||
.rev()
|
||||
.map(|chunk| std::str::from_utf8(chunk).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
if n < 0 {
|
||||
format!("-{}", with_commas)
|
||||
} else {
|
||||
with_commas
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn query(q: &str) -> Vec<LaunchItem> {
|
||||
CalculatorProvider.query(q)
|
||||
}
|
||||
|
||||
// --- Trigger prefix tests ---
|
||||
|
||||
#[test]
|
||||
fn equals_prefix_addition() {
|
||||
let results = query("= 5+3");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].name, "8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calc_prefix_multiplication() {
|
||||
let results = query("calc 10*2");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].name, "20");
|
||||
}
|
||||
|
||||
// --- Auto-detect tests ---
|
||||
|
||||
#[test]
|
||||
fn auto_detect_addition() {
|
||||
let results = query("5+3");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].name, "8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_prefix_complex_expression() {
|
||||
let results = query("= sqrt(16) + 2^3");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].name, "12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_result() {
|
||||
let results = query("= 10/3");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(
|
||||
results[0].name.starts_with("3.333"),
|
||||
"expected result starting with 3.333, got: {}",
|
||||
results[0].name
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_integer_thousands_separators() {
|
||||
let results = query("= 1000000");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].name, "1,000,000");
|
||||
}
|
||||
|
||||
// --- Invalid / non-math input ---
|
||||
|
||||
#[test]
|
||||
fn invalid_expression_returns_empty() {
|
||||
let results = query("= 5 +");
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_returns_empty() {
|
||||
let results = query("firefox");
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
// --- Metadata tests ---
|
||||
|
||||
#[test]
|
||||
fn provider_type_is_calc_plugin() {
|
||||
assert_eq!(
|
||||
CalculatorProvider.provider_type(),
|
||||
ProviderType::Plugin("calc".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_shows_expression() {
|
||||
let results = query("= 5+3");
|
||||
assert_eq!(results[0].description.as_deref(), Some("= 5+3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_command_contains_wl_copy() {
|
||||
let results = query("= 5+3");
|
||||
assert!(results[0].command.contains("wl-copy"));
|
||||
}
|
||||
}
|
||||
313
crates/owlry-core/src/providers/converter/currency.rs
Normal file
313
crates/owlry-core/src/providers/converter/currency.rs
Normal file
@@ -0,0 +1,313 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ECB_URL: &str = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml";
|
||||
const CACHE_MAX_AGE_SECS: u64 = 86400; // 24 hours
|
||||
|
||||
static CACHED_RATES: Mutex<Option<CurrencyRates>> = Mutex::new(None);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CurrencyRates {
|
||||
pub date: String,
|
||||
pub rates: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
struct CurrencyAlias {
|
||||
code: &'static str,
|
||||
aliases: &'static [&'static str],
|
||||
}
|
||||
|
||||
static CURRENCY_ALIASES: &[CurrencyAlias] = &[
|
||||
CurrencyAlias {
|
||||
code: "EUR",
|
||||
aliases: &["eur", "euro", "euros", "€"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "USD",
|
||||
aliases: &["usd", "dollar", "dollars", "$", "us_dollar"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "GBP",
|
||||
aliases: &["gbp", "pound_sterling", "£", "british_pound", "pounds"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "JPY",
|
||||
aliases: &["jpy", "yen", "¥", "japanese_yen"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "CHF",
|
||||
aliases: &["chf", "swiss_franc", "francs"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "CAD",
|
||||
aliases: &["cad", "canadian_dollar", "c$"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "AUD",
|
||||
aliases: &["aud", "australian_dollar", "a$"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "CNY",
|
||||
aliases: &["cny", "yuan", "renminbi", "rmb"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "SEK",
|
||||
aliases: &["sek", "swedish_krona", "kronor"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "NOK",
|
||||
aliases: &["nok", "norwegian_krone"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "DKK",
|
||||
aliases: &["dkk", "danish_krone"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "PLN",
|
||||
aliases: &["pln", "zloty", "złoty"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "CZK",
|
||||
aliases: &["czk", "czech_koruna"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "HUF",
|
||||
aliases: &["huf", "forint"],
|
||||
},
|
||||
CurrencyAlias {
|
||||
code: "TRY",
|
||||
aliases: &["try", "turkish_lira", "lira"],
|
||||
},
|
||||
];
|
||||
|
||||
pub fn resolve_currency_code(alias: &str) -> Option<&'static str> {
|
||||
let lower = alias.to_lowercase();
|
||||
|
||||
// Check aliases
|
||||
for ca in CURRENCY_ALIASES {
|
||||
if ca.aliases.contains(&lower.as_str()) {
|
||||
return Some(ca.code); // ca.code is already &'static str
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a raw 3-letter ISO code we know about
|
||||
let upper = alias.to_uppercase();
|
||||
if upper.len() == 3 {
|
||||
if upper == "EUR" {
|
||||
return Some("EUR");
|
||||
}
|
||||
if let Some(rates) = get_rates()
|
||||
&& rates.rates.contains_key(&upper)
|
||||
{
|
||||
for ca in CURRENCY_ALIASES {
|
||||
if ca.code == upper {
|
||||
return Some(ca.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_currency_alias(alias: &str) -> bool {
|
||||
resolve_currency_code(alias).is_some()
|
||||
}
|
||||
|
||||
pub fn get_rates() -> Option<CurrencyRates> {
|
||||
// Check memory cache first
|
||||
{
|
||||
let cache = CACHED_RATES.lock().ok()?;
|
||||
if let Some(ref rates) = *cache {
|
||||
return Some(rates.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Try disk cache
|
||||
if let Some(rates) = load_cache()
|
||||
&& !is_stale(&rates)
|
||||
{
|
||||
let mut cache = CACHED_RATES.lock().ok()?;
|
||||
*cache = Some(rates.clone());
|
||||
return Some(rates);
|
||||
}
|
||||
|
||||
// Fetch fresh rates
|
||||
if let Some(rates) = fetch_rates() {
|
||||
save_cache(&rates);
|
||||
let mut cache = CACHED_RATES.lock().ok()?;
|
||||
*cache = Some(rates.clone());
|
||||
return Some(rates);
|
||||
}
|
||||
|
||||
// Fall back to stale cache
|
||||
load_cache()
|
||||
}
|
||||
|
||||
fn cache_path() -> Option<PathBuf> {
|
||||
let cache_dir = dirs::cache_dir()?.join("owlry");
|
||||
Some(cache_dir.join("ecb_rates.json"))
|
||||
}
|
||||
|
||||
fn load_cache() -> Option<CurrencyRates> {
|
||||
let path = cache_path()?;
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
fn save_cache(rates: &CurrencyRates) {
|
||||
if let Some(path) = cache_path() {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).ok();
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string_pretty(rates) {
|
||||
fs::write(path, json).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_stale(_rates: &CurrencyRates) -> bool {
|
||||
let path = match cache_path() {
|
||||
Some(p) => p,
|
||||
None => return true,
|
||||
};
|
||||
let metadata = match fs::metadata(path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return true,
|
||||
};
|
||||
let modified = match metadata.modified() {
|
||||
Ok(t) => t,
|
||||
Err(_) => return true,
|
||||
};
|
||||
match SystemTime::now().duration_since(modified) {
|
||||
Ok(age) => age.as_secs() > CACHE_MAX_AGE_SECS,
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_rates() -> Option<CurrencyRates> {
|
||||
let response = reqwest::blocking::get(ECB_URL).ok()?;
|
||||
let body = response.text().ok()?;
|
||||
parse_ecb_xml(&body)
|
||||
}
|
||||
|
||||
fn parse_ecb_xml(xml: &str) -> Option<CurrencyRates> {
|
||||
let mut rates = HashMap::new();
|
||||
let mut date = String::new();
|
||||
|
||||
for line in xml.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Extract date: <Cube time='2026-03-26'>
|
||||
if trimmed.contains("time=")
|
||||
&& let Some(start) = trimmed.find("time='")
|
||||
{
|
||||
let rest = &trimmed[start + 6..];
|
||||
if let Some(end) = rest.find('\'') {
|
||||
date = rest[..end].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Extract rate: <Cube currency='USD' rate='1.0832'/>
|
||||
if trimmed.contains("currency=") && trimmed.contains("rate=") {
|
||||
let currency = extract_attr(trimmed, "currency")?;
|
||||
let rate_str = extract_attr(trimmed, "rate")?;
|
||||
let rate: f64 = rate_str.parse().ok()?;
|
||||
rates.insert(currency, rate);
|
||||
}
|
||||
}
|
||||
|
||||
if rates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(CurrencyRates { date, rates })
|
||||
}
|
||||
|
||||
fn extract_attr(line: &str, attr: &str) -> Option<String> {
|
||||
let needle = format!("{}='", attr);
|
||||
let start = line.find(&needle)? + needle.len();
|
||||
let rest = &line[start..];
|
||||
let end = rest.find('\'')?;
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_currency_code_iso() {
|
||||
assert_eq!(resolve_currency_code("usd"), Some("USD"));
|
||||
assert_eq!(resolve_currency_code("EUR"), Some("EUR"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_currency_code_name() {
|
||||
assert_eq!(resolve_currency_code("dollar"), Some("USD"));
|
||||
assert_eq!(resolve_currency_code("euro"), Some("EUR"));
|
||||
assert_eq!(resolve_currency_code("pounds"), Some("GBP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_currency_code_symbol() {
|
||||
assert_eq!(resolve_currency_code("$"), Some("USD"));
|
||||
assert_eq!(resolve_currency_code("€"), Some("EUR"));
|
||||
assert_eq!(resolve_currency_code("£"), Some("GBP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_currency_unknown() {
|
||||
assert_eq!(resolve_currency_code("xyz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_currency_alias() {
|
||||
assert!(is_currency_alias("usd"));
|
||||
assert!(is_currency_alias("euro"));
|
||||
assert!(is_currency_alias("$"));
|
||||
assert!(!is_currency_alias("km"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ecb_xml() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gesmes:Envelope xmlns:gesmes="http://www.gesmes.org/xml/2002-08-01" xmlns="http://www.ecb.int/vocabulary/2002-08-01/eurofxref">
|
||||
<gesmes:subject>Reference rates</gesmes:subject>
|
||||
<Cube>
|
||||
<Cube time='2026-03-26'>
|
||||
<Cube currency='USD' rate='1.0832'/>
|
||||
<Cube currency='JPY' rate='161.94'/>
|
||||
<Cube currency='GBP' rate='0.83450'/>
|
||||
</Cube>
|
||||
</Cube>
|
||||
</gesmes:Envelope>"#;
|
||||
|
||||
let rates = parse_ecb_xml(xml).unwrap();
|
||||
assert!((rates.rates["USD"] - 1.0832).abs() < 0.001);
|
||||
assert!((rates.rates["GBP"] - 0.8345).abs() < 0.001);
|
||||
assert!((rates.rates["JPY"] - 161.94).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_roundtrip() {
|
||||
let rates = CurrencyRates {
|
||||
date: "2026-03-26".to_string(),
|
||||
rates: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("USD".to_string(), 1.0832);
|
||||
m.insert("GBP".to_string(), 0.8345);
|
||||
m
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&rates).unwrap();
|
||||
let parsed: CurrencyRates = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.rates["USD"], 1.0832);
|
||||
}
|
||||
}
|
||||
183
crates/owlry-core/src/providers/converter/mod.rs
Normal file
183
crates/owlry-core/src/providers/converter/mod.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
mod currency;
|
||||
mod parser;
|
||||
mod units;
|
||||
|
||||
use super::{DynamicProvider, LaunchItem, ProviderType};
|
||||
|
||||
const PROVIDER_TYPE_ID: &str = "conv";
|
||||
const PROVIDER_ICON: &str = "edit-find-replace-symbolic";
|
||||
|
||||
pub struct ConverterProvider;
|
||||
|
||||
impl ConverterProvider {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl DynamicProvider for ConverterProvider {
|
||||
fn name(&self) -> &str {
|
||||
"Converter"
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Plugin(PROVIDER_TYPE_ID.into())
|
||||
}
|
||||
|
||||
fn priority(&self) -> u32 {
|
||||
9_000
|
||||
}
|
||||
|
||||
fn query(&self, query: &str) -> Vec<LaunchItem> {
|
||||
let query_str = query.trim();
|
||||
// Strip prefix
|
||||
let input = if let Some(rest) = query_str.strip_prefix('>') {
|
||||
rest.trim()
|
||||
} else {
|
||||
query_str
|
||||
};
|
||||
|
||||
let parsed = match parser::parse_conversion(input) {
|
||||
Some(p) => p,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let results = if let Some(ref target) = parsed.target_unit {
|
||||
units::convert_to(&parsed.value, &parsed.from_unit, target)
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
units::convert_common(&parsed.value, &parsed.from_unit)
|
||||
};
|
||||
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| LaunchItem {
|
||||
id: format!("conv:{}:{}:{}", parsed.from_unit, r.target_symbol, r.value),
|
||||
name: r.display_value.clone(),
|
||||
description: Some(format!(
|
||||
"{} {} = {}",
|
||||
format_number(parsed.value),
|
||||
parsed.from_symbol,
|
||||
r.display_value,
|
||||
)),
|
||||
icon: Some(PROVIDER_ICON.into()),
|
||||
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
|
||||
command: format!(
|
||||
"printf '%s' '{}' | wl-copy",
|
||||
r.raw_value.replace('\'', "'\\''")
|
||||
),
|
||||
terminal: false,
|
||||
tags: vec!["converter".into(), "units".into()],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_number(n: f64) -> String {
|
||||
if n.fract() == 0.0 && n.abs() < 1e15 {
|
||||
let i = n as i64;
|
||||
if i.abs() >= 1000 {
|
||||
format_with_separators(i)
|
||||
} else {
|
||||
format!("{}", i)
|
||||
}
|
||||
} else {
|
||||
format!("{:.4}", n)
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_with_separators(n: i64) -> String {
|
||||
let s = n.abs().to_string();
|
||||
let mut result = String::new();
|
||||
for (i, c) in s.chars().rev().enumerate() {
|
||||
if i > 0 && i % 3 == 0 {
|
||||
result.push(',');
|
||||
}
|
||||
result.push(c);
|
||||
}
|
||||
if n < 0 {
|
||||
result.push('-');
|
||||
}
|
||||
result.chars().rev().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn query(input: &str) -> Vec<LaunchItem> {
|
||||
ConverterProvider::new().query(input)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix_trigger() {
|
||||
let r = query("> 100 km to mi");
|
||||
assert!(!r.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_detect() {
|
||||
let r = query("100 km to mi");
|
||||
assert!(!r.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_conversions() {
|
||||
let r = query("> 100 km");
|
||||
assert!(r.len() > 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_temperature() {
|
||||
let r = query("102F to C");
|
||||
assert!(!r.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonsense_returns_empty() {
|
||||
assert!(query("hello world").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type() {
|
||||
assert_eq!(
|
||||
ConverterProvider::new().provider_type(),
|
||||
ProviderType::Plugin("conv".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_double_unit() {
|
||||
let r = query("100 km to mi");
|
||||
if let Some(item) = r.first() {
|
||||
let desc = item.description.as_deref().unwrap();
|
||||
assert!(!desc.ends_with(" mi mi"), "double unit in: {}", desc);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_number_integer() {
|
||||
assert_eq!(format_number(42.0), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_number_large_integer() {
|
||||
assert_eq!(format_number(1000000.0), "1,000,000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_number_decimal() {
|
||||
assert_eq!(format_number(3.14), "3.14");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_with_separators() {
|
||||
assert_eq!(format_with_separators(1234567), "1,234,567");
|
||||
assert_eq!(format_with_separators(999), "999");
|
||||
assert_eq!(format_with_separators(-1234), "-1,234");
|
||||
}
|
||||
}
|
||||
235
crates/owlry-core/src/providers/converter/parser.rs
Normal file
235
crates/owlry-core/src/providers/converter/parser.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
use super::units;
|
||||
|
||||
pub struct ParsedQuery {
|
||||
pub value: f64,
|
||||
pub from_unit: String,
|
||||
pub from_symbol: String,
|
||||
pub target_unit: Option<String>,
|
||||
}
|
||||
|
||||
pub fn parse_conversion(input: &str) -> Option<ParsedQuery> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract leading number
|
||||
let (value, rest) = extract_number(input)?;
|
||||
let rest = rest.trim();
|
||||
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Split on " to " or " in " (case-insensitive)
|
||||
let (from_str, target_str) = split_on_connector(rest);
|
||||
|
||||
// Resolve from unit
|
||||
let from_lower = from_str.trim().to_lowercase();
|
||||
let from_symbol = units::find_unit(&from_lower)?;
|
||||
|
||||
let from_symbol_str = from_symbol.to_string();
|
||||
|
||||
// Resolve target unit if present
|
||||
let target_unit = target_str.and_then(|t| {
|
||||
let t_lower = t.trim().to_lowercase();
|
||||
if t_lower.is_empty() {
|
||||
None
|
||||
} else {
|
||||
units::find_unit(&t_lower).map(|_| t_lower)
|
||||
}
|
||||
});
|
||||
|
||||
Some(ParsedQuery {
|
||||
value,
|
||||
from_unit: from_lower,
|
||||
from_symbol: from_symbol_str,
|
||||
target_unit,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_number(input: &str) -> Option<(f64, &str)> {
|
||||
let bytes = input.as_bytes();
|
||||
let mut i = 0;
|
||||
|
||||
// Optional negative sign
|
||||
if i < bytes.len() && bytes[i] == b'-' {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Must have at least one digit or start with .
|
||||
if i >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let start_digits = i;
|
||||
|
||||
// Integer part
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Decimal part
|
||||
if i < bytes.len() && bytes[i] == b'.' {
|
||||
i += 1;
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if i == start_digits && !(i > 0 && bytes[0] == b'-') {
|
||||
// No digits found (and not just a negative sign before a dot)
|
||||
// Handle ".5" case
|
||||
if bytes[start_digits] == b'.' {
|
||||
// already advanced past dot above
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
if i == 0 || (i == 1 && bytes[0] == b'-') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let num_str = &input[..i];
|
||||
let value: f64 = num_str.parse().ok()?;
|
||||
let rest = &input[i..];
|
||||
|
||||
Some((value, rest))
|
||||
}
|
||||
|
||||
fn split_on_connector(input: &str) -> (&str, Option<&str>) {
|
||||
let lower = input.to_lowercase();
|
||||
|
||||
// Try " to " first
|
||||
if let Some(pos) = lower.find(" to ") {
|
||||
let from = &input[..pos];
|
||||
let target = &input[pos + 4..];
|
||||
return (from, Some(target));
|
||||
}
|
||||
|
||||
// Try " in "
|
||||
if let Some(pos) = lower.find(" in ") {
|
||||
let from = &input[..pos];
|
||||
let target = &input[pos + 4..];
|
||||
return (from, Some(target));
|
||||
}
|
||||
|
||||
(input, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_number_and_unit_with_space() {
|
||||
let p = parse_conversion("100 km").unwrap();
|
||||
assert!((p.value - 100.0).abs() < 0.001);
|
||||
assert_eq!(p.from_unit, "km");
|
||||
assert!(p.target_unit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_number_and_unit_no_space() {
|
||||
let p = parse_conversion("100km").unwrap();
|
||||
assert!((p.value - 100.0).abs() < 0.001);
|
||||
assert_eq!(p.from_unit, "km");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_target_to() {
|
||||
let p = parse_conversion("100 km to mi").unwrap();
|
||||
assert!((p.value - 100.0).abs() < 0.001);
|
||||
assert_eq!(p.from_unit, "km");
|
||||
assert_eq!(p.target_unit.as_deref(), Some("mi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_target_in() {
|
||||
let p = parse_conversion("100 km in mi").unwrap();
|
||||
assert_eq!(p.target_unit.as_deref(), Some("mi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_temperature_no_space() {
|
||||
let p = parse_conversion("102F to C").unwrap();
|
||||
assert!((p.value - 102.0).abs() < 0.001);
|
||||
assert_eq!(p.from_unit, "f");
|
||||
assert_eq!(p.target_unit.as_deref(), Some("c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_temperature_with_space() {
|
||||
let p = parse_conversion("102 F in K").unwrap();
|
||||
assert!((p.value - 102.0).abs() < 0.001);
|
||||
assert_eq!(p.from_unit, "f");
|
||||
assert_eq!(p.target_unit.as_deref(), Some("k"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_number() {
|
||||
let p = parse_conversion("3.5 kg to lb").unwrap();
|
||||
assert!((p.value - 3.5).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_starting_with_dot() {
|
||||
let p = parse_conversion(".5 kg").unwrap();
|
||||
assert!((p.value - 0.5).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_unit_names() {
|
||||
let p = parse_conversion("100 kilometers to miles").unwrap();
|
||||
assert_eq!(p.from_unit, "kilometers");
|
||||
assert_eq!(p.target_unit.as_deref(), Some("miles"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive() {
|
||||
let p = parse_conversion("100 KM TO MI").unwrap();
|
||||
assert_eq!(p.from_unit, "km");
|
||||
assert_eq!(p.target_unit.as_deref(), Some("mi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_currency() {
|
||||
let p = parse_conversion("100 eur to usd").unwrap();
|
||||
assert_eq!(p.from_unit, "eur");
|
||||
assert_eq!(p.target_unit.as_deref(), Some("usd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_number_returns_none() {
|
||||
assert!(parse_conversion("km to mi").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_unit_returns_none() {
|
||||
assert!(parse_conversion("100 xyz to abc").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_returns_none() {
|
||||
assert!(parse_conversion("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_number_only_returns_none() {
|
||||
assert!(parse_conversion("100").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compound_unit_alias() {
|
||||
let p = parse_conversion("100 km/h to mph").unwrap();
|
||||
assert_eq!(p.from_unit, "km/h");
|
||||
assert_eq!(p.target_unit.as_deref(), Some("mph"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_word_unit() {
|
||||
let p = parse_conversion("100 fl_oz to ml").unwrap();
|
||||
assert_eq!(p.from_unit, "fl_oz");
|
||||
}
|
||||
}
|
||||
944
crates/owlry-core/src/providers/converter/units.rs
Normal file
944
crates/owlry-core/src/providers/converter/units.rs
Normal file
@@ -0,0 +1,944 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use super::currency;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Category {
|
||||
Temperature,
|
||||
Length,
|
||||
Weight,
|
||||
Volume,
|
||||
Speed,
|
||||
Area,
|
||||
Data,
|
||||
Time,
|
||||
Pressure,
|
||||
Energy,
|
||||
Currency,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Conversion {
|
||||
Factor(f64),
|
||||
Custom {
|
||||
to_base: fn(f64) -> f64,
|
||||
from_base: fn(f64) -> f64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct UnitDef {
|
||||
_id: &'static str,
|
||||
symbol: &'static str,
|
||||
aliases: &'static [&'static str],
|
||||
category: Category,
|
||||
conversion: Conversion,
|
||||
}
|
||||
|
||||
impl UnitDef {
|
||||
fn to_base(&self, value: f64) -> f64 {
|
||||
match &self.conversion {
|
||||
Conversion::Factor(f) => value * f,
|
||||
Conversion::Custom { to_base, .. } => to_base(value),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_from_base(&self, value: f64) -> f64 {
|
||||
match &self.conversion {
|
||||
Conversion::Factor(f) => value / f,
|
||||
Conversion::Custom { from_base, .. } => from_base(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ConversionResult {
|
||||
pub value: f64,
|
||||
pub raw_value: String,
|
||||
pub display_value: String,
|
||||
pub target_symbol: String,
|
||||
}
|
||||
|
||||
static UNITS: LazyLock<Vec<UnitDef>> = LazyLock::new(build_unit_table);
|
||||
static ALIAS_MAP: LazyLock<HashMap<String, usize>> = LazyLock::new(|| {
|
||||
let mut map = HashMap::new();
|
||||
for (i, unit) in UNITS.iter().enumerate() {
|
||||
for alias in unit.aliases {
|
||||
map.insert(alias.to_lowercase(), i);
|
||||
}
|
||||
}
|
||||
map
|
||||
});
|
||||
|
||||
// Common conversions per category (symbols to show when no target specified)
|
||||
static COMMON_TARGETS: LazyLock<HashMap<Category, Vec<&'static str>>> = LazyLock::new(|| {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(Category::Temperature, vec!["°C", "°F", "K"]);
|
||||
m.insert(Category::Length, vec!["m", "km", "ft", "mi", "in"]);
|
||||
m.insert(Category::Weight, vec!["kg", "lb", "oz", "g", "st"]);
|
||||
m.insert(Category::Volume, vec!["l", "gal", "ml", "cup", "fl oz"]);
|
||||
m.insert(Category::Speed, vec!["km/h", "mph", "m/s", "kn"]);
|
||||
m.insert(Category::Area, vec!["m²", "ft²", "ac", "ha", "km²"]);
|
||||
m.insert(Category::Data, vec!["MB", "GB", "MiB", "GiB", "TB"]);
|
||||
m.insert(Category::Time, vec!["s", "min", "h", "d", "wk"]);
|
||||
m.insert(Category::Pressure, vec!["bar", "psi", "atm", "hPa", "mmHg"]);
|
||||
m.insert(Category::Energy, vec!["kJ", "kcal", "kWh", "BTU", "Wh"]);
|
||||
m.insert(Category::Currency, vec!["USD", "EUR", "GBP", "JPY", "CNY"]);
|
||||
m
|
||||
});
|
||||
|
||||
pub fn find_unit(alias: &str) -> Option<&'static str> {
|
||||
let lower = alias.to_lowercase();
|
||||
if let Some(&i) = ALIAS_MAP.get(&lower) {
|
||||
return Some(UNITS[i].symbol);
|
||||
}
|
||||
currency::resolve_currency_code(&lower)
|
||||
}
|
||||
|
||||
pub fn lookup_unit(alias: &str) -> Option<(usize, &UnitDef)> {
|
||||
let lower = alias.to_lowercase();
|
||||
ALIAS_MAP.get(&lower).map(|&i| (i, &UNITS[i]))
|
||||
}
|
||||
|
||||
pub fn convert_to(value: &f64, from: &str, to: &str) -> Option<ConversionResult> {
|
||||
// Try currency first — currency aliases (dollar, euro, etc.) aren't in the UNITS table
|
||||
if currency::is_currency_alias(from) || currency::is_currency_alias(to) {
|
||||
return convert_currency(*value, from, to);
|
||||
}
|
||||
|
||||
let (_, from_def) = lookup_unit(from)?;
|
||||
let (_, to_def) = lookup_unit(to)?;
|
||||
|
||||
// Currency via UNITS table (shouldn't reach here, but just in case)
|
||||
if from_def.category == Category::Currency || to_def.category == Category::Currency {
|
||||
return convert_currency(*value, from, to);
|
||||
}
|
||||
|
||||
// Must be same category
|
||||
if from_def.category != to_def.category {
|
||||
return None;
|
||||
}
|
||||
|
||||
let base_value = from_def.to_base(*value);
|
||||
let result = to_def.convert_from_base(base_value);
|
||||
|
||||
Some(format_result(result, to_def.symbol))
|
||||
}
|
||||
|
||||
pub fn convert_common(value: &f64, from: &str) -> Vec<ConversionResult> {
|
||||
// Try currency first — currency aliases (dollar, euro, etc.) aren't in the UNITS table
|
||||
if currency::is_currency_alias(from) {
|
||||
return convert_currency_common(*value, from);
|
||||
}
|
||||
|
||||
let (_, from_def) = match lookup_unit(from) {
|
||||
Some(u) => u,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let category = from_def.category;
|
||||
let from_symbol = from_def.symbol;
|
||||
|
||||
if category == Category::Currency {
|
||||
return convert_currency_common(*value, from);
|
||||
}
|
||||
|
||||
let targets = match COMMON_TARGETS.get(&category) {
|
||||
Some(t) => t,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let base_value = from_def.to_base(*value);
|
||||
|
||||
targets
|
||||
.iter()
|
||||
.filter(|&&sym| sym != from_symbol)
|
||||
.filter_map(|&sym| {
|
||||
let (_, to_def) = lookup_unit(sym)?;
|
||||
let result = to_def.convert_from_base(base_value);
|
||||
Some(format_result(result, to_def.symbol))
|
||||
})
|
||||
.take(5)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn convert_currency(value: f64, from: &str, to: &str) -> Option<ConversionResult> {
|
||||
let rates = currency::get_rates()?;
|
||||
let from_code = currency::resolve_currency_code(from)?;
|
||||
let to_code = currency::resolve_currency_code(to)?;
|
||||
|
||||
let from_rate = if from_code == "EUR" { 1.0 } else { *rates.rates.get(from_code)? };
|
||||
let to_rate = if to_code == "EUR" { 1.0 } else { *rates.rates.get(to_code)? };
|
||||
|
||||
let result = value / from_rate * to_rate;
|
||||
Some(format_currency_result(result, to_code))
|
||||
}
|
||||
|
||||
fn convert_currency_common(value: f64, from: &str) -> Vec<ConversionResult> {
|
||||
let rates = match currency::get_rates() {
|
||||
Some(r) => r,
|
||||
None => return vec![],
|
||||
};
|
||||
let from_code = match currency::resolve_currency_code(from) {
|
||||
Some(c) => c,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let targets = COMMON_TARGETS.get(&Category::Currency).unwrap();
|
||||
let from_rate = if from_code == "EUR" {
|
||||
1.0
|
||||
} else {
|
||||
match rates.rates.get(from_code) {
|
||||
Some(&r) => r,
|
||||
None => return vec![],
|
||||
}
|
||||
};
|
||||
|
||||
targets
|
||||
.iter()
|
||||
.filter(|&&sym| sym != from_code)
|
||||
.filter_map(|&sym| {
|
||||
let to_rate = if sym == "EUR" { 1.0 } else { *rates.rates.get(sym)? };
|
||||
let result = value / from_rate * to_rate;
|
||||
Some(format_currency_result(result, sym))
|
||||
})
|
||||
.take(5)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn format_result(value: f64, symbol: &str) -> ConversionResult {
|
||||
let raw = if value.fract() == 0.0 && value.abs() < 1e15 {
|
||||
format!("{}", value as i64)
|
||||
} else {
|
||||
format!("{:.4}", value)
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_string()
|
||||
};
|
||||
|
||||
let display = if value.abs() >= 1000.0 && value.fract() == 0.0 && value.abs() < 1e15 {
|
||||
super::format_with_separators(value as i64)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
ConversionResult {
|
||||
value,
|
||||
raw_value: raw,
|
||||
display_value: format!("{} {}", display, symbol),
|
||||
target_symbol: symbol.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_currency_result(value: f64, code: &str) -> ConversionResult {
|
||||
let raw = format!("{:.2}", value);
|
||||
let display = raw.clone();
|
||||
|
||||
ConversionResult {
|
||||
value,
|
||||
raw_value: raw,
|
||||
display_value: format!("{} {}", display, code),
|
||||
target_symbol: code.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_unit_table() -> Vec<UnitDef> {
|
||||
vec![
|
||||
// Temperature (base: Kelvin)
|
||||
UnitDef {
|
||||
_id: "celsius",
|
||||
symbol: "°C",
|
||||
aliases: &["c", "°c", "celsius", "degc", "centigrade"],
|
||||
category: Category::Temperature,
|
||||
conversion: Conversion::Custom {
|
||||
to_base: |v| v + 273.15,
|
||||
from_base: |v| v - 273.15,
|
||||
},
|
||||
},
|
||||
UnitDef {
|
||||
_id: "fahrenheit",
|
||||
symbol: "°F",
|
||||
aliases: &["f", "°f", "fahrenheit", "degf"],
|
||||
category: Category::Temperature,
|
||||
conversion: Conversion::Custom {
|
||||
to_base: |v| (v - 32.0) * 5.0 / 9.0 + 273.15,
|
||||
from_base: |v| (v - 273.15) * 9.0 / 5.0 + 32.0,
|
||||
},
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kelvin",
|
||||
symbol: "K",
|
||||
aliases: &["k", "kelvin"],
|
||||
category: Category::Temperature,
|
||||
conversion: Conversion::Factor(1.0), // base
|
||||
},
|
||||
// Length (base: meter)
|
||||
UnitDef {
|
||||
_id: "millimeter",
|
||||
symbol: "mm",
|
||||
aliases: &["mm", "millimeter", "millimeters", "millimetre"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(0.001),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "centimeter",
|
||||
symbol: "cm",
|
||||
aliases: &["cm", "centimeter", "centimeters", "centimetre"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(0.01),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "meter",
|
||||
symbol: "m",
|
||||
aliases: &["m", "meter", "meters", "metre", "metres"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kilometer",
|
||||
symbol: "km",
|
||||
aliases: &["km", "kms", "kilometer", "kilometers", "kilometre"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(1000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "inch",
|
||||
symbol: "in",
|
||||
aliases: &["in", "inch", "inches"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(0.0254),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "foot",
|
||||
symbol: "ft",
|
||||
aliases: &["ft", "foot", "feet"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(0.3048),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "yard",
|
||||
symbol: "yd",
|
||||
aliases: &["yd", "yard", "yards"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(0.9144),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "mile",
|
||||
symbol: "mi",
|
||||
aliases: &["mi", "mile", "miles"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(1609.344),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "nautical_mile",
|
||||
symbol: "nmi",
|
||||
aliases: &["nmi", "nautical_mile", "nautical_miles"],
|
||||
category: Category::Length,
|
||||
conversion: Conversion::Factor(1852.0),
|
||||
},
|
||||
// Weight (base: kg)
|
||||
UnitDef {
|
||||
_id: "milligram",
|
||||
symbol: "mg",
|
||||
aliases: &["mg", "milligram", "milligrams"],
|
||||
category: Category::Weight,
|
||||
conversion: Conversion::Factor(0.000001),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "gram",
|
||||
symbol: "g",
|
||||
aliases: &["g", "gram", "grams"],
|
||||
category: Category::Weight,
|
||||
conversion: Conversion::Factor(0.001),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kilogram",
|
||||
symbol: "kg",
|
||||
aliases: &["kg", "kilogram", "kilograms", "kilo", "kilos"],
|
||||
category: Category::Weight,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "tonne",
|
||||
symbol: "t",
|
||||
aliases: &["t", "ton", "tons", "tonne", "tonnes", "metric_ton"],
|
||||
category: Category::Weight,
|
||||
conversion: Conversion::Factor(1000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "short_ton",
|
||||
symbol: "short_ton",
|
||||
aliases: &["short_ton", "ton_us"],
|
||||
category: Category::Weight,
|
||||
conversion: Conversion::Factor(907.185),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "ounce",
|
||||
symbol: "oz",
|
||||
aliases: &["oz", "ounce", "ounces"],
|
||||
category: Category::Weight,
|
||||
conversion: Conversion::Factor(0.0283495),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "pound",
|
||||
symbol: "lb",
|
||||
aliases: &["lb", "lbs", "pound", "pounds"],
|
||||
category: Category::Weight,
|
||||
conversion: Conversion::Factor(0.453592),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "stone",
|
||||
symbol: "st",
|
||||
aliases: &["st", "stone", "stones"],
|
||||
category: Category::Weight,
|
||||
conversion: Conversion::Factor(6.35029),
|
||||
},
|
||||
// Volume (base: liter)
|
||||
UnitDef {
|
||||
_id: "milliliter",
|
||||
symbol: "ml",
|
||||
aliases: &["ml", "milliliter", "milliliters", "millilitre"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(0.001),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "liter",
|
||||
symbol: "l",
|
||||
aliases: &["l", "liter", "liters", "litre", "litres"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "us_gallon",
|
||||
symbol: "gal",
|
||||
aliases: &["gal", "gallon", "gallons"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(3.78541),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "imp_gallon",
|
||||
symbol: "imp gal",
|
||||
aliases: &["imp_gal", "gal_uk", "imperial_gallon"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(4.54609),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "quart",
|
||||
symbol: "qt",
|
||||
aliases: &["qt", "quart", "quarts"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(0.946353),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "pint",
|
||||
symbol: "pt",
|
||||
aliases: &["pt", "pint", "pints"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(0.473176),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "cup",
|
||||
symbol: "cup",
|
||||
aliases: &["cup", "cups"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(0.236588),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "fluid_ounce",
|
||||
symbol: "fl oz",
|
||||
aliases: &["floz", "fl_oz", "fluid_ounce", "fluid_ounces"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(0.0295735),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "tablespoon",
|
||||
symbol: "tbsp",
|
||||
aliases: &["tbsp", "tablespoon", "tablespoons"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(0.0147868),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "teaspoon",
|
||||
symbol: "tsp",
|
||||
aliases: &["tsp", "teaspoon", "teaspoons"],
|
||||
category: Category::Volume,
|
||||
conversion: Conversion::Factor(0.00492892),
|
||||
},
|
||||
// Speed (base: m/s)
|
||||
UnitDef {
|
||||
_id: "mps",
|
||||
symbol: "m/s",
|
||||
aliases: &["m/s", "mps", "meters_per_second"],
|
||||
category: Category::Speed,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kmh",
|
||||
symbol: "km/h",
|
||||
aliases: &["km/h", "kmh", "kph", "kilometers_per_hour"],
|
||||
category: Category::Speed,
|
||||
conversion: Conversion::Factor(0.277778),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "mph",
|
||||
symbol: "mph",
|
||||
aliases: &["mph", "miles_per_hour"],
|
||||
category: Category::Speed,
|
||||
conversion: Conversion::Factor(0.44704),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "knot",
|
||||
symbol: "kn",
|
||||
aliases: &["kn", "kt", "knot", "knots"],
|
||||
category: Category::Speed,
|
||||
conversion: Conversion::Factor(0.514444),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "fps",
|
||||
symbol: "ft/s",
|
||||
aliases: &["ft/s", "fps", "feet_per_second"],
|
||||
category: Category::Speed,
|
||||
conversion: Conversion::Factor(0.3048),
|
||||
},
|
||||
// Area (base: m²)
|
||||
UnitDef {
|
||||
_id: "sqmm",
|
||||
symbol: "mm²",
|
||||
aliases: &["mm2", "sqmm", "square_millimeter"],
|
||||
category: Category::Area,
|
||||
conversion: Conversion::Factor(0.000001),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "sqcm",
|
||||
symbol: "cm²",
|
||||
aliases: &["cm2", "sqcm", "square_centimeter"],
|
||||
category: Category::Area,
|
||||
conversion: Conversion::Factor(0.0001),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "sqm",
|
||||
symbol: "m²",
|
||||
aliases: &["m2", "sqm", "square_meter", "square_meters"],
|
||||
category: Category::Area,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "sqkm",
|
||||
symbol: "km²",
|
||||
aliases: &["km2", "sqkm", "square_kilometer"],
|
||||
category: Category::Area,
|
||||
conversion: Conversion::Factor(1000000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "sqft",
|
||||
symbol: "ft²",
|
||||
aliases: &["ft2", "sqft", "square_foot", "square_feet"],
|
||||
category: Category::Area,
|
||||
conversion: Conversion::Factor(0.092903),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "acre",
|
||||
symbol: "ac",
|
||||
aliases: &["ac", "acre", "acres"],
|
||||
category: Category::Area,
|
||||
conversion: Conversion::Factor(4046.86),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "hectare",
|
||||
symbol: "ha",
|
||||
aliases: &["ha", "hectare", "hectares"],
|
||||
category: Category::Area,
|
||||
conversion: Conversion::Factor(10000.0),
|
||||
},
|
||||
// Data (base: byte)
|
||||
UnitDef {
|
||||
_id: "byte",
|
||||
symbol: "B",
|
||||
aliases: &["b", "byte", "bytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kilobyte",
|
||||
symbol: "KB",
|
||||
aliases: &["kb", "kilobyte", "kilobytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "megabyte",
|
||||
symbol: "MB",
|
||||
aliases: &["mb", "megabyte", "megabytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1_000_000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "gigabyte",
|
||||
symbol: "GB",
|
||||
aliases: &["gb", "gigabyte", "gigabytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1_000_000_000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "terabyte",
|
||||
symbol: "TB",
|
||||
aliases: &["tb", "terabyte", "terabytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1_000_000_000_000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kibibyte",
|
||||
symbol: "KiB",
|
||||
aliases: &["kib", "kibibyte", "kibibytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1024.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "mebibyte",
|
||||
symbol: "MiB",
|
||||
aliases: &["mib", "mebibyte", "mebibytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1_048_576.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "gibibyte",
|
||||
symbol: "GiB",
|
||||
aliases: &["gib", "gibibyte", "gibibytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1_073_741_824.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "tebibyte",
|
||||
symbol: "TiB",
|
||||
aliases: &["tib", "tebibyte", "tebibytes"],
|
||||
category: Category::Data,
|
||||
conversion: Conversion::Factor(1_099_511_627_776.0),
|
||||
},
|
||||
// Time (base: second)
|
||||
UnitDef {
|
||||
_id: "second",
|
||||
symbol: "s",
|
||||
aliases: &["s", "sec", "second", "seconds"],
|
||||
category: Category::Time,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "minute",
|
||||
symbol: "min",
|
||||
aliases: &["min", "minute", "minutes"],
|
||||
category: Category::Time,
|
||||
conversion: Conversion::Factor(60.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "hour",
|
||||
symbol: "h",
|
||||
aliases: &["h", "hr", "hour", "hours"],
|
||||
category: Category::Time,
|
||||
conversion: Conversion::Factor(3600.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "day",
|
||||
symbol: "d",
|
||||
aliases: &["d", "day", "days"],
|
||||
category: Category::Time,
|
||||
conversion: Conversion::Factor(86400.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "week",
|
||||
symbol: "wk",
|
||||
aliases: &["wk", "week", "weeks"],
|
||||
category: Category::Time,
|
||||
conversion: Conversion::Factor(604800.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "month",
|
||||
symbol: "mo",
|
||||
aliases: &["mo", "month", "months"],
|
||||
category: Category::Time,
|
||||
conversion: Conversion::Factor(2_592_000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "year",
|
||||
symbol: "yr",
|
||||
aliases: &["yr", "year", "years"],
|
||||
category: Category::Time,
|
||||
conversion: Conversion::Factor(31_536_000.0),
|
||||
},
|
||||
// Pressure (base: Pa)
|
||||
UnitDef {
|
||||
_id: "pascal",
|
||||
symbol: "Pa",
|
||||
aliases: &["pa", "pascal", "pascals"],
|
||||
category: Category::Pressure,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "hectopascal",
|
||||
symbol: "hPa",
|
||||
aliases: &["hpa", "hectopascal"],
|
||||
category: Category::Pressure,
|
||||
conversion: Conversion::Factor(100.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kilopascal",
|
||||
symbol: "kPa",
|
||||
aliases: &["kpa", "kilopascal"],
|
||||
category: Category::Pressure,
|
||||
conversion: Conversion::Factor(1000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "bar",
|
||||
symbol: "bar",
|
||||
aliases: &["bar", "bars"],
|
||||
category: Category::Pressure,
|
||||
conversion: Conversion::Factor(100_000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "millibar",
|
||||
symbol: "mbar",
|
||||
aliases: &["mbar", "millibar"],
|
||||
category: Category::Pressure,
|
||||
conversion: Conversion::Factor(100.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "psi",
|
||||
symbol: "psi",
|
||||
aliases: &["psi", "pounds_per_square_inch"],
|
||||
category: Category::Pressure,
|
||||
conversion: Conversion::Factor(6894.76),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "atmosphere",
|
||||
symbol: "atm",
|
||||
aliases: &["atm", "atmosphere", "atmospheres"],
|
||||
category: Category::Pressure,
|
||||
conversion: Conversion::Factor(101_325.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "mmhg",
|
||||
symbol: "mmHg",
|
||||
aliases: &["mmhg", "torr"],
|
||||
category: Category::Pressure,
|
||||
conversion: Conversion::Factor(133.322),
|
||||
},
|
||||
// Energy (base: Joule)
|
||||
UnitDef {
|
||||
_id: "joule",
|
||||
symbol: "J",
|
||||
aliases: &["j", "joule", "joules"],
|
||||
category: Category::Energy,
|
||||
conversion: Conversion::Factor(1.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kilojoule",
|
||||
symbol: "kJ",
|
||||
aliases: &["kj", "kilojoule", "kilojoules"],
|
||||
category: Category::Energy,
|
||||
conversion: Conversion::Factor(1000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "calorie",
|
||||
symbol: "cal",
|
||||
aliases: &["cal", "calorie", "calories"],
|
||||
category: Category::Energy,
|
||||
conversion: Conversion::Factor(4.184),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kilocalorie",
|
||||
symbol: "kcal",
|
||||
aliases: &["kcal", "kilocalorie", "kilocalories"],
|
||||
category: Category::Energy,
|
||||
conversion: Conversion::Factor(4184.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "watt_hour",
|
||||
symbol: "Wh",
|
||||
aliases: &["wh", "watt_hour"],
|
||||
category: Category::Energy,
|
||||
conversion: Conversion::Factor(3600.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "kilowatt_hour",
|
||||
symbol: "kWh",
|
||||
aliases: &["kwh", "kilowatt_hour"],
|
||||
category: Category::Energy,
|
||||
conversion: Conversion::Factor(3_600_000.0),
|
||||
},
|
||||
UnitDef {
|
||||
_id: "btu",
|
||||
symbol: "BTU",
|
||||
aliases: &["btu", "british_thermal_unit"],
|
||||
category: Category::Energy,
|
||||
conversion: Conversion::Factor(1055.06),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_celsius_to_fahrenheit() {
|
||||
let r = convert_to(&100.0, "c", "f").unwrap();
|
||||
assert!((r.value - 212.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fahrenheit_to_celsius() {
|
||||
let r = convert_to(&32.0, "f", "c").unwrap();
|
||||
assert!((r.value - 0.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_body_temp_f_to_c() {
|
||||
let r = convert_to(&98.6, "f", "c").unwrap();
|
||||
assert!((r.value - 37.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_km_to_miles() {
|
||||
let r = convert_to(&100.0, "km", "mi").unwrap();
|
||||
assert!((r.value - 62.1371).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_miles_to_km() {
|
||||
let r = convert_to(&1.0, "mi", "km").unwrap();
|
||||
assert!((r.value - 1.60934).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kg_to_lb() {
|
||||
let r = convert_to(&1.0, "kg", "lb").unwrap();
|
||||
assert!((r.value - 2.20462).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lb_to_kg() {
|
||||
let r = convert_to(&100.0, "lbs", "kg").unwrap();
|
||||
assert!((r.value - 45.3592).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_liters_to_gallons() {
|
||||
let r = convert_to(&3.78541, "l", "gal").unwrap();
|
||||
assert!((r.value - 1.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kmh_to_mph() {
|
||||
let r = convert_to(&100.0, "kmh", "mph").unwrap();
|
||||
assert!((r.value - 62.1371).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gb_to_mb() {
|
||||
let r = convert_to(&1.0, "gb", "mb").unwrap();
|
||||
assert!((r.value - 1000.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gib_to_mib() {
|
||||
let r = convert_to(&1.0, "gib", "mib").unwrap();
|
||||
assert!((r.value - 1024.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hours_to_minutes() {
|
||||
let r = convert_to(&2.5, "h", "min").unwrap();
|
||||
assert!((r.value - 150.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bar_to_psi() {
|
||||
let r = convert_to(&1.0, "bar", "psi").unwrap();
|
||||
assert!((r.value - 14.5038).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kcal_to_kj() {
|
||||
let r = convert_to(&1.0, "kcal", "kj").unwrap();
|
||||
assert!((r.value - 4.184).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sqm_to_sqft() {
|
||||
let r = convert_to(&1.0, "m2", "ft2").unwrap();
|
||||
assert!((r.value - 10.7639).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_unit_returns_none() {
|
||||
assert!(convert_to(&100.0, "xyz", "abc").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cross_category_returns_none() {
|
||||
assert!(convert_to(&100.0, "km", "kg").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_common_returns_results() {
|
||||
let results = convert_common(&100.0, "km");
|
||||
assert!(!results.is_empty());
|
||||
assert!(results.len() <= 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_common_excludes_source() {
|
||||
let results = convert_common(&100.0, "km");
|
||||
for r in &results {
|
||||
assert_ne!(r.target_symbol, "km");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alias_case_insensitive() {
|
||||
let r1 = convert_to(&100.0, "KM", "MI").unwrap();
|
||||
let r2 = convert_to(&100.0, "km", "mi").unwrap();
|
||||
assert!((r1.value - r2.value).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_name_alias() {
|
||||
let r = convert_to(&100.0, "kilometers", "miles").unwrap();
|
||||
assert!((r.value - 62.1371).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_currency_two_decimals() {
|
||||
let r = convert_to(&1.0, "km", "mi").unwrap();
|
||||
// display_value should have reasonable formatting
|
||||
assert!(!r.display_value.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_currency_alias_convert_to() {
|
||||
// "dollar" and "euro" are aliases, not in the UNITS table
|
||||
let r = convert_to(&20.0, "dollar", "euro");
|
||||
// May return None if ECB rates unavailable (network), but should not panic
|
||||
// In a network-available environment, this should return Some
|
||||
if let Some(r) = r {
|
||||
assert!(r.value > 0.0);
|
||||
assert_eq!(r.target_symbol, "EUR");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_currency_alias_convert_common() {
|
||||
let results = convert_common(&20.0, "dollar");
|
||||
// May be empty if ECB rates unavailable, but should not panic
|
||||
for r in &results {
|
||||
assert!(r.value > 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_value_no_double_unit() {
|
||||
let r = convert_to(&100.0, "km", "mi").unwrap();
|
||||
// display_value should contain the symbol exactly once
|
||||
let count = r.display_value.matches(&r.target_symbol).count();
|
||||
assert_eq!(count, 1, "display_value '{}' should contain '{}' exactly once", r.display_value, r.target_symbol);
|
||||
}
|
||||
}
|
||||
@@ -89,10 +89,12 @@ impl Provider for LuaProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// LuaProvider needs to be Send for the Provider trait
|
||||
// Since we're using Rc<RefCell<>>, we need to be careful about thread safety
|
||||
// For now, owlry is single-threaded, so this is safe
|
||||
// LuaProvider needs to be Send + Sync for the Provider trait.
|
||||
// Rc<RefCell<>> is !Send and !Sync, but the ProviderManager RwLock ensures
|
||||
// Rc<RefCell<>> is only accessed during refresh() (write lock = exclusive access).
|
||||
// Read-only operations (items(), search) only touch self.items (Vec<LaunchItem>).
|
||||
unsafe impl Send for LuaProvider {}
|
||||
unsafe impl Sync for LuaProvider {}
|
||||
|
||||
/// Create LuaProviders from all registered providers in a plugin
|
||||
pub fn create_providers_from_plugin(plugin: Rc<RefCell<LoadedPlugin>>) -> Vec<Box<dyn Provider>> {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Core providers (no plugin equivalents)
|
||||
mod application;
|
||||
mod command;
|
||||
pub(crate) mod calculator;
|
||||
pub(crate) mod converter;
|
||||
pub(crate) mod system;
|
||||
|
||||
// Native plugin bridge
|
||||
pub mod native_provider;
|
||||
@@ -16,6 +19,7 @@ pub use command::CommandProvider;
|
||||
// Re-export native provider for plugin loading
|
||||
pub use native_provider::NativeProvider;
|
||||
|
||||
use chrono::Utc;
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use log::info;
|
||||
@@ -25,6 +29,7 @@ use log::debug;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::data::FrecencyStore;
|
||||
use crate::plugins::runtime_loader::LoadedRuntime;
|
||||
|
||||
/// Metadata descriptor for an available provider (used by IPC/daemon API)
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -94,7 +99,7 @@ impl std::fmt::Display for ProviderType {
|
||||
}
|
||||
|
||||
/// Trait for all search providers
|
||||
pub trait Provider: Send {
|
||||
pub trait Provider: Send + Sync {
|
||||
#[allow(dead_code)]
|
||||
fn name(&self) -> &str;
|
||||
fn provider_type(&self) -> ProviderType;
|
||||
@@ -102,10 +107,24 @@ pub trait Provider: Send {
|
||||
fn items(&self) -> &[LaunchItem];
|
||||
}
|
||||
|
||||
/// Trait for built-in providers that produce results per-keystroke.
|
||||
/// Unlike static `Provider`s which cache items via `refresh()`/`items()`,
|
||||
/// dynamic providers generate results on every query.
|
||||
pub(crate) trait DynamicProvider: Send + Sync {
|
||||
#[allow(dead_code)]
|
||||
fn name(&self) -> &str;
|
||||
fn provider_type(&self) -> ProviderType;
|
||||
fn query(&self, query: &str) -> Vec<LaunchItem>;
|
||||
fn priority(&self) -> u32;
|
||||
}
|
||||
|
||||
/// Manages all providers and handles searching
|
||||
pub struct ProviderManager {
|
||||
/// Core static providers (apps, commands, dmenu)
|
||||
providers: Vec<Box<dyn Provider>>,
|
||||
/// Built-in dynamic providers (calculator, converter)
|
||||
/// These are queried per-keystroke, like native dynamic plugins
|
||||
builtin_dynamic: Vec<Box<dyn DynamicProvider>>,
|
||||
/// Static native plugin providers (need query() for submenu support)
|
||||
static_native_providers: Vec<NativeProvider>,
|
||||
/// Dynamic providers from native plugins (calculator, websearch, filesearch)
|
||||
@@ -116,6 +135,10 @@ pub struct ProviderManager {
|
||||
widget_providers: Vec<NativeProvider>,
|
||||
/// Fuzzy matcher for search
|
||||
matcher: SkimMatcherV2,
|
||||
/// Loaded script runtimes (Lua, Rune) — must stay alive to keep Library handles
|
||||
runtimes: Vec<LoadedRuntime>,
|
||||
/// Type IDs of providers from script runtimes (for hot-reload removal)
|
||||
runtime_type_ids: std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
@@ -130,10 +153,13 @@ impl ProviderManager {
|
||||
) -> Self {
|
||||
let mut manager = Self {
|
||||
providers: core_providers,
|
||||
builtin_dynamic: Vec::new(),
|
||||
static_native_providers: Vec::new(),
|
||||
dynamic_providers: Vec::new(),
|
||||
widget_providers: Vec::new(),
|
||||
matcher: SkimMatcherV2::default(),
|
||||
runtimes: Vec::new(),
|
||||
runtime_type_ids: std::collections::HashSet::new(),
|
||||
};
|
||||
|
||||
// Categorize native plugins based on their declared ProviderKind and ProviderPosition
|
||||
@@ -170,6 +196,25 @@ impl ProviderManager {
|
||||
manager
|
||||
}
|
||||
|
||||
/// Get type IDs of built-in providers (for conflict detection with native plugins)
|
||||
fn builtin_type_ids(&self) -> std::collections::HashSet<String> {
|
||||
let mut ids: std::collections::HashSet<String> = self
|
||||
.builtin_dynamic
|
||||
.iter()
|
||||
.filter_map(|p| match p.provider_type() {
|
||||
ProviderType::Plugin(id) => Some(id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// Also include built-in static providers that use Plugin type
|
||||
for p in &self.providers {
|
||||
if let ProviderType::Plugin(id) = p.provider_type() {
|
||||
ids.insert(id);
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// Create a self-contained ProviderManager from config.
|
||||
///
|
||||
/// Loads native plugins, creates core providers (Application + Command),
|
||||
@@ -180,7 +225,7 @@ impl ProviderManager {
|
||||
use std::sync::Arc;
|
||||
|
||||
// Create core providers
|
||||
let core_providers: Vec<Box<dyn Provider>> = vec![
|
||||
let mut core_providers: Vec<Box<dyn Provider>> = vec![
|
||||
Box::new(ApplicationProvider::new()),
|
||||
Box::new(CommandProvider::new()),
|
||||
];
|
||||
@@ -220,7 +265,178 @@ impl ProviderManager {
|
||||
}
|
||||
};
|
||||
|
||||
Self::new(core_providers, native_providers)
|
||||
// Load script runtimes (Lua, Rune) for user plugins
|
||||
let mut runtime_providers: Vec<Box<dyn Provider>> = Vec::new();
|
||||
let mut runtimes: Vec<LoadedRuntime> = Vec::new();
|
||||
let mut runtime_type_ids = std::collections::HashSet::new();
|
||||
let owlry_version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
let skip_runtimes = std::env::var("OWLRY_SKIP_RUNTIMES").is_ok();
|
||||
if !skip_runtimes
|
||||
&& let Some(plugins_dir) = crate::paths::plugins_dir()
|
||||
{
|
||||
// Try Lua runtime
|
||||
match LoadedRuntime::load_lua(&plugins_dir, owlry_version) {
|
||||
Ok(rt) => {
|
||||
info!("Loaded Lua runtime with {} provider(s)", rt.providers().len());
|
||||
for provider in rt.create_providers() {
|
||||
let type_id = format!("{}", provider.provider_type());
|
||||
runtime_type_ids.insert(type_id);
|
||||
runtime_providers.push(provider);
|
||||
}
|
||||
runtimes.push(rt);
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Lua runtime not available: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Try Rune runtime
|
||||
match LoadedRuntime::load_rune(&plugins_dir, owlry_version) {
|
||||
Ok(rt) => {
|
||||
info!("Loaded Rune runtime with {} provider(s)", rt.providers().len());
|
||||
for provider in rt.create_providers() {
|
||||
let type_id = format!("{}", provider.provider_type());
|
||||
runtime_type_ids.insert(type_id);
|
||||
runtime_providers.push(provider);
|
||||
}
|
||||
runtimes.push(rt);
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Rune runtime not available: {}", e);
|
||||
}
|
||||
}
|
||||
} // skip_runtimes
|
||||
|
||||
// Merge runtime providers into core providers
|
||||
for provider in runtime_providers {
|
||||
info!("Registered runtime provider: {}", provider.name());
|
||||
core_providers.push(provider);
|
||||
}
|
||||
|
||||
// Built-in dynamic providers
|
||||
let mut builtin_dynamic: Vec<Box<dyn DynamicProvider>> = Vec::new();
|
||||
|
||||
if config.providers.calculator {
|
||||
builtin_dynamic.push(Box::new(calculator::CalculatorProvider));
|
||||
info!("Registered built-in calculator provider");
|
||||
}
|
||||
|
||||
if config.providers.converter {
|
||||
builtin_dynamic.push(Box::new(converter::ConverterProvider::new()));
|
||||
info!("Registered built-in converter provider");
|
||||
}
|
||||
|
||||
// Built-in static providers
|
||||
if config.providers.system {
|
||||
core_providers.push(Box::new(system::SystemProvider::new()));
|
||||
info!("Registered built-in system provider");
|
||||
}
|
||||
|
||||
// Compute built-in type IDs to detect conflicts with native plugins.
|
||||
// A native plugin whose type_id matches a built-in provider would
|
||||
// produce duplicate results, so we skip it.
|
||||
let builtin_ids: std::collections::HashSet<String> = {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
// Dynamic built-ins (calculator, converter)
|
||||
for p in &builtin_dynamic {
|
||||
if let ProviderType::Plugin(id) = p.provider_type() {
|
||||
ids.insert(id);
|
||||
}
|
||||
}
|
||||
// Static built-ins added to core_providers (e.g. system)
|
||||
for p in &core_providers {
|
||||
if let ProviderType::Plugin(id) = p.provider_type() {
|
||||
ids.insert(id);
|
||||
}
|
||||
}
|
||||
ids
|
||||
};
|
||||
|
||||
let native_providers: Vec<NativeProvider> = native_providers
|
||||
.into_iter()
|
||||
.filter(|provider| {
|
||||
let type_id = provider.type_id();
|
||||
if builtin_ids.contains(type_id) {
|
||||
info!(
|
||||
"Skipping native plugin '{}' — built-in provider exists",
|
||||
type_id
|
||||
);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut manager = Self::new(core_providers, native_providers);
|
||||
manager.builtin_dynamic = builtin_dynamic;
|
||||
manager.runtimes = runtimes;
|
||||
manager.runtime_type_ids = runtime_type_ids;
|
||||
manager
|
||||
}
|
||||
|
||||
/// Reload all script runtime providers (called by filesystem watcher)
|
||||
pub fn reload_runtimes(&mut self) {
|
||||
use crate::plugins::runtime_loader::LoadedRuntime;
|
||||
|
||||
// Remove old runtime providers from the core providers list
|
||||
self.providers.retain(|p| {
|
||||
let type_str = format!("{}", p.provider_type());
|
||||
!self.runtime_type_ids.contains(&type_str)
|
||||
});
|
||||
|
||||
// Drop old runtimes (catch panics from runtime cleanup)
|
||||
let old_runtimes = std::mem::take(&mut self.runtimes);
|
||||
drop(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
drop(old_runtimes);
|
||||
})));
|
||||
self.runtime_type_ids.clear();
|
||||
|
||||
let owlry_version = env!("CARGO_PKG_VERSION");
|
||||
let plugins_dir = match crate::paths::plugins_dir() {
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Reload Lua runtime
|
||||
match LoadedRuntime::load_lua(&plugins_dir, owlry_version) {
|
||||
Ok(rt) => {
|
||||
info!("Reloaded Lua runtime with {} provider(s)", rt.providers().len());
|
||||
for provider in rt.create_providers() {
|
||||
let type_id = format!("{}", provider.provider_type());
|
||||
self.runtime_type_ids.insert(type_id);
|
||||
self.providers.push(provider);
|
||||
}
|
||||
self.runtimes.push(rt);
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Lua runtime not available on reload: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Reload Rune runtime
|
||||
match LoadedRuntime::load_rune(&plugins_dir, owlry_version) {
|
||||
Ok(rt) => {
|
||||
info!("Reloaded Rune runtime with {} provider(s)", rt.providers().len());
|
||||
for provider in rt.create_providers() {
|
||||
let type_id = format!("{}", provider.provider_type());
|
||||
self.runtime_type_ids.insert(type_id);
|
||||
self.providers.push(provider);
|
||||
}
|
||||
self.runtimes.push(rt);
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Rune runtime not available on reload: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the newly added providers
|
||||
for provider in &mut self.providers {
|
||||
provider.refresh();
|
||||
}
|
||||
|
||||
info!("Runtime reload complete");
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -448,6 +664,7 @@ impl ProviderManager {
|
||||
query, max_results, frecency_weight
|
||||
);
|
||||
|
||||
let now = Utc::now();
|
||||
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
|
||||
|
||||
// Add widget items first (highest priority) - only when:
|
||||
@@ -478,8 +695,42 @@ impl ProviderManager {
|
||||
let dynamic_results = provider.query(query);
|
||||
// Priority comes from plugin-declared priority field
|
||||
let base_score = provider.priority() as i64;
|
||||
|
||||
// Auto-detect plugins (calc, conv) get a grouping bonus so
|
||||
// all their results stay together above generic search results
|
||||
let grouping_bonus: i64 = match provider.provider_type() {
|
||||
ProviderType::Plugin(ref id)
|
||||
if matches!(id.as_str(), "calc" | "conv") =>
|
||||
{
|
||||
10_000
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
for (idx, item) in dynamic_results.into_iter().enumerate() {
|
||||
results.push((item, base_score - idx as i64));
|
||||
results.push((item, base_score + grouping_bonus - idx as i64));
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in dynamic providers (calculator, converter)
|
||||
for provider in &self.builtin_dynamic {
|
||||
if !filter.is_active(provider.provider_type()) {
|
||||
continue;
|
||||
}
|
||||
let dynamic_results = provider.query(query);
|
||||
let base_score = provider.priority() as i64;
|
||||
|
||||
let grouping_bonus: i64 = match provider.provider_type() {
|
||||
ProviderType::Plugin(ref id)
|
||||
if matches!(id.as_str(), "calc" | "conv") =>
|
||||
{
|
||||
10_000
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
for (idx, item) in dynamic_results.into_iter().enumerate() {
|
||||
results.push((item, base_score + grouping_bonus - idx as i64));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -511,7 +762,7 @@ impl ProviderManager {
|
||||
}
|
||||
})
|
||||
.map(|item| {
|
||||
let frecency_score = frecency.get_score(&item.id);
|
||||
let frecency_score = frecency.get_score_at(&item.id, now);
|
||||
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
|
||||
(item, boosted)
|
||||
})
|
||||
@@ -560,9 +811,20 @@ impl ProviderManager {
|
||||
};
|
||||
|
||||
base_score.map(|s| {
|
||||
let frecency_score = frecency.get_score(&item.id);
|
||||
let frecency_score = frecency.get_score_at(&item.id, now);
|
||||
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
|
||||
(item.clone(), s + frecency_boost)
|
||||
|
||||
// Exact name match bonus — apps get a higher boost
|
||||
let exact_match_boost = if item.name.eq_ignore_ascii_case(query) {
|
||||
match &item.provider {
|
||||
ProviderType::Application => 50_000,
|
||||
_ => 30_000,
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
(item.clone(), s + frecency_boost + exact_match_boost)
|
||||
})
|
||||
};
|
||||
|
||||
@@ -954,4 +1216,24 @@ mod tests {
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].0.name, "Firefox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builtin_type_ids_includes_dynamic_and_static() {
|
||||
use super::calculator::CalculatorProvider;
|
||||
use super::converter::ConverterProvider;
|
||||
use super::system::SystemProvider;
|
||||
|
||||
let mut pm = ProviderManager::new(
|
||||
vec![Box::new(SystemProvider::new())],
|
||||
vec![],
|
||||
);
|
||||
pm.builtin_dynamic = vec![
|
||||
Box::new(CalculatorProvider),
|
||||
Box::new(ConverterProvider::new()),
|
||||
];
|
||||
let ids = pm.builtin_type_ids();
|
||||
assert!(ids.contains("calc"));
|
||||
assert!(ids.contains("conv"));
|
||||
assert!(ids.contains("sys"));
|
||||
}
|
||||
}
|
||||
|
||||
148
crates/owlry-core/src/providers/system.rs
Normal file
148
crates/owlry-core/src/providers/system.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
|
||||
/// Built-in system provider. Returns a fixed set of power and session management actions.
|
||||
///
|
||||
/// This is a static provider — items are populated in `new()` and `refresh()` is a no-op.
|
||||
pub(crate) struct SystemProvider {
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl SystemProvider {
|
||||
pub fn new() -> Self {
|
||||
let commands: &[(&str, &str, &str, &str, &str)] = &[
|
||||
(
|
||||
"shutdown",
|
||||
"Shutdown",
|
||||
"Power off the system",
|
||||
"system-shutdown",
|
||||
"systemctl poweroff",
|
||||
),
|
||||
(
|
||||
"reboot",
|
||||
"Reboot",
|
||||
"Restart the system",
|
||||
"system-reboot",
|
||||
"systemctl reboot",
|
||||
),
|
||||
(
|
||||
"reboot-bios",
|
||||
"Reboot to BIOS",
|
||||
"Restart into UEFI/BIOS setup",
|
||||
"system-reboot",
|
||||
"systemctl reboot --firmware-setup",
|
||||
),
|
||||
(
|
||||
"suspend",
|
||||
"Suspend",
|
||||
"Suspend to RAM",
|
||||
"system-suspend",
|
||||
"systemctl suspend",
|
||||
),
|
||||
(
|
||||
"hibernate",
|
||||
"Hibernate",
|
||||
"Suspend to disk",
|
||||
"system-suspend-hibernate",
|
||||
"systemctl hibernate",
|
||||
),
|
||||
(
|
||||
"lock",
|
||||
"Lock Screen",
|
||||
"Lock the session",
|
||||
"system-lock-screen",
|
||||
"loginctl lock-session",
|
||||
),
|
||||
(
|
||||
"logout",
|
||||
"Log Out",
|
||||
"End the current session",
|
||||
"system-log-out",
|
||||
"loginctl terminate-session self",
|
||||
),
|
||||
];
|
||||
|
||||
let items = commands
|
||||
.iter()
|
||||
.map(|(action_id, name, description, icon, command)| LaunchItem {
|
||||
id: format!("sys:{}", action_id),
|
||||
name: name.to_string(),
|
||||
description: Some(description.to_string()),
|
||||
icon: Some(icon.to_string()),
|
||||
provider: ProviderType::Plugin("sys".into()),
|
||||
command: command.to_string(),
|
||||
terminal: false,
|
||||
tags: vec!["system".into()],
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self { items }
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for SystemProvider {
|
||||
fn name(&self) -> &str {
|
||||
"System"
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Plugin("sys".into())
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
// Static provider — no-op
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn has_seven_actions() {
|
||||
let provider = SystemProvider::new();
|
||||
assert_eq!(provider.items().len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_expected_action_names() {
|
||||
let provider = SystemProvider::new();
|
||||
let names: Vec<&str> = provider.items().iter().map(|i| i.name.as_str()).collect();
|
||||
assert!(names.contains(&"Shutdown"));
|
||||
assert!(names.contains(&"Reboot"));
|
||||
assert!(names.contains(&"Lock Screen"));
|
||||
assert!(names.contains(&"Log Out"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_type_is_sys_plugin() {
|
||||
let provider = SystemProvider::new();
|
||||
assert_eq!(provider.provider_type(), ProviderType::Plugin("sys".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_command_is_correct() {
|
||||
let provider = SystemProvider::new();
|
||||
let shutdown = provider
|
||||
.items()
|
||||
.iter()
|
||||
.find(|i| i.name == "Shutdown")
|
||||
.expect("Shutdown item must exist");
|
||||
assert_eq!(shutdown.command, "systemctl poweroff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_items_have_system_tag() {
|
||||
let provider = SystemProvider::new();
|
||||
for item in provider.items() {
|
||||
assert!(
|
||||
item.tags.contains(&"system".to_string()),
|
||||
"item '{}' is missing 'system' tag",
|
||||
item.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
use std::thread;
|
||||
|
||||
/// Maximum allowed size for a single IPC request line (1 MiB).
|
||||
const MAX_REQUEST_SIZE: usize = 1_048_576;
|
||||
|
||||
use log::{error, info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
@@ -17,8 +22,8 @@ use crate::providers::{LaunchItem, ProviderManager};
|
||||
pub struct Server {
|
||||
listener: UnixListener,
|
||||
socket_path: PathBuf,
|
||||
provider_manager: Arc<Mutex<ProviderManager>>,
|
||||
frecency: Arc<Mutex<FrecencyStore>>,
|
||||
provider_manager: Arc<RwLock<ProviderManager>>,
|
||||
frecency: Arc<RwLock<FrecencyStore>>,
|
||||
config: Arc<Config>,
|
||||
}
|
||||
|
||||
@@ -34,6 +39,7 @@ impl Server {
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(socket_path)?;
|
||||
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
info!("IPC server listening on {:?}", socket_path);
|
||||
|
||||
let config = Config::load_or_default();
|
||||
@@ -43,14 +49,17 @@ impl Server {
|
||||
Ok(Self {
|
||||
listener,
|
||||
socket_path: socket_path.to_path_buf(),
|
||||
provider_manager: Arc::new(Mutex::new(provider_manager)),
|
||||
frecency: Arc::new(Mutex::new(frecency)),
|
||||
provider_manager: Arc::new(RwLock::new(provider_manager)),
|
||||
frecency: Arc::new(RwLock::new(frecency)),
|
||||
config: Arc::new(config),
|
||||
})
|
||||
}
|
||||
|
||||
/// Accept connections in a loop, spawning a thread per client.
|
||||
pub fn run(&self) -> io::Result<()> {
|
||||
// Start filesystem watcher for user plugin hot-reload
|
||||
crate::plugins::watcher::start_watching(Arc::clone(&self.provider_manager));
|
||||
|
||||
info!("Server entering accept loop");
|
||||
for stream in self.listener.incoming() {
|
||||
match stream {
|
||||
@@ -90,15 +99,33 @@ impl Server {
|
||||
/// dispatch each, and write the JSON response back.
|
||||
fn handle_client(
|
||||
stream: UnixStream,
|
||||
pm: Arc<Mutex<ProviderManager>>,
|
||||
frecency: Arc<Mutex<FrecencyStore>>,
|
||||
pm: Arc<RwLock<ProviderManager>>,
|
||||
frecency: Arc<RwLock<FrecencyStore>>,
|
||||
config: Arc<Config>,
|
||||
) -> io::Result<()> {
|
||||
let reader = BufReader::new(stream.try_clone()?);
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
|
||||
let mut reader = BufReader::new(stream.try_clone()?);
|
||||
let mut writer = stream;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let bytes_read = reader.read_line(&mut line)?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
if line.len() > MAX_REQUEST_SIZE {
|
||||
let resp = Response::Error {
|
||||
message: format!(
|
||||
"request too large ({} bytes, max {})",
|
||||
line.len(),
|
||||
MAX_REQUEST_SIZE
|
||||
),
|
||||
};
|
||||
write_response(&mut writer, &resp)?;
|
||||
break;
|
||||
}
|
||||
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
@@ -107,6 +134,7 @@ impl Server {
|
||||
let request: Request = match serde_json::from_str(trimmed) {
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
warn!("Malformed request from client: {}", e);
|
||||
let resp = Response::Error {
|
||||
message: format!("invalid request JSON: {}", e),
|
||||
};
|
||||
@@ -126,8 +154,8 @@ impl Server {
|
||||
/// the response.
|
||||
fn handle_request(
|
||||
request: &Request,
|
||||
pm: &Arc<Mutex<ProviderManager>>,
|
||||
frecency: &Arc<Mutex<FrecencyStore>>,
|
||||
pm: &Arc<RwLock<ProviderManager>>,
|
||||
frecency: &Arc<RwLock<FrecencyStore>>,
|
||||
config: &Arc<Config>,
|
||||
) -> Response {
|
||||
match request {
|
||||
@@ -139,8 +167,8 @@ impl Server {
|
||||
let max = config.general.max_results;
|
||||
let weight = config.providers.frecency_weight;
|
||||
|
||||
let pm_guard = pm.lock().unwrap();
|
||||
let frecency_guard = frecency.lock().unwrap();
|
||||
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
|
||||
let frecency_guard = frecency.read().unwrap_or_else(|e| e.into_inner());
|
||||
let results = pm_guard.search_with_frecency(
|
||||
text,
|
||||
max,
|
||||
@@ -162,13 +190,13 @@ impl Server {
|
||||
item_id,
|
||||
provider: _,
|
||||
} => {
|
||||
let mut frecency_guard = frecency.lock().unwrap();
|
||||
let mut frecency_guard = frecency.write().unwrap_or_else(|e| e.into_inner());
|
||||
frecency_guard.record_launch(item_id);
|
||||
Response::Ack
|
||||
}
|
||||
|
||||
Request::Providers => {
|
||||
let pm_guard = pm.lock().unwrap();
|
||||
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
|
||||
let descs = pm_guard.available_providers();
|
||||
Response::Providers {
|
||||
list: descs.into_iter().map(descriptor_to_desc).collect(),
|
||||
@@ -176,7 +204,7 @@ impl Server {
|
||||
}
|
||||
|
||||
Request::Refresh { provider } => {
|
||||
let mut pm_guard = pm.lock().unwrap();
|
||||
let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner());
|
||||
pm_guard.refresh_provider(provider);
|
||||
Response::Ack
|
||||
}
|
||||
@@ -187,7 +215,7 @@ impl Server {
|
||||
}
|
||||
|
||||
Request::Submenu { plugin_id, data } => {
|
||||
let pm_guard = pm.lock().unwrap();
|
||||
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
|
||||
match pm_guard.query_submenu_actions(plugin_id, data, plugin_id) {
|
||||
Some((_name, actions)) => Response::SubmenuItems {
|
||||
items: actions
|
||||
@@ -202,7 +230,7 @@ impl Server {
|
||||
}
|
||||
|
||||
Request::PluginAction { command } => {
|
||||
let pm_guard = pm.lock().unwrap();
|
||||
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
|
||||
if pm_guard.execute_plugin_action(command) {
|
||||
Response::Ack
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owlry-lua"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -22,7 +22,7 @@ pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> {
|
||||
}
|
||||
|
||||
/// Implementation of owlry.provider.register()
|
||||
fn register_provider(_lua: &Lua, config: Table) -> LuaResult<()> {
|
||||
fn register_provider(lua: &Lua, config: Table) -> LuaResult<()> {
|
||||
let name: String = config.get("name")?;
|
||||
let display_name: String = config
|
||||
.get::<Option<String>>("display_name")?
|
||||
@@ -47,6 +47,21 @@ fn register_provider(_lua: &Lua, config: Table) -> LuaResult<()> {
|
||||
|
||||
let is_dynamic = has_query;
|
||||
|
||||
// Store the config table in owlry.provider._registrations[name]
|
||||
// so call_refresh/call_query can find the callback functions later
|
||||
let globals = lua.globals();
|
||||
let owlry: Table = globals.get("owlry")?;
|
||||
let provider: Table = owlry.get("provider")?;
|
||||
let registrations: Table = match provider.get::<Value>("_registrations")? {
|
||||
Value::Table(t) => t,
|
||||
_ => {
|
||||
let t = lua.create_table()?;
|
||||
provider.set("_registrations", t.clone())?;
|
||||
t
|
||||
}
|
||||
};
|
||||
registrations.set(name.as_str(), config)?;
|
||||
|
||||
REGISTRATIONS.with(|regs| {
|
||||
regs.borrow_mut().push(ProviderRegistration {
|
||||
name,
|
||||
|
||||
@@ -27,28 +27,19 @@ mod manifest;
|
||||
mod runtime;
|
||||
|
||||
use abi_stable::std_types::{ROption, RStr, RString, RVec};
|
||||
use owlry_plugin_api::{PluginItem, ProviderKind};
|
||||
use owlry_plugin_api::PluginItem;
|
||||
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,
|
||||
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
|
||||
/// Get provider infos from all loaded plugins
|
||||
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<LuaProviderInfo>,
|
||||
/// Refresh a provider's items
|
||||
@@ -66,11 +57,8 @@ pub struct LuaRuntimeVTable {
|
||||
/// 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
|
||||
@@ -106,24 +94,22 @@ impl RuntimeHandle {
|
||||
}
|
||||
|
||||
/// Provider info from a Lua plugin
|
||||
///
|
||||
/// Must match ScriptProviderInfo layout in owlry-core/src/plugins/runtime_loader.rs
|
||||
#[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,
|
||||
/// Provider name (used as vtable refresh/query key: "plugin_id:provider_name")
|
||||
pub name: RString,
|
||||
/// Display name
|
||||
pub display_name: RString,
|
||||
/// Optional prefix trigger
|
||||
pub prefix: ROption<RString>,
|
||||
/// Icon name
|
||||
pub icon: RString,
|
||||
/// Provider type (static/dynamic)
|
||||
pub provider_type: ProviderKind,
|
||||
/// Type ID for filtering
|
||||
pub type_id: RString,
|
||||
/// Icon name
|
||||
pub default_icon: RString,
|
||||
/// Whether this is a static provider (true) or dynamic (false)
|
||||
pub is_static: bool,
|
||||
/// Optional prefix trigger
|
||||
pub prefix: ROption<RString>,
|
||||
}
|
||||
|
||||
/// Internal runtime state
|
||||
@@ -187,21 +173,14 @@ impl LuaRuntimeState {
|
||||
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()),
|
||||
name: RString::from(full_id),
|
||||
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()),
|
||||
default_icon: RString::from(reg.default_icon.as_str()),
|
||||
is_static: !reg.is_dynamic,
|
||||
prefix: reg.prefix.map(RString::from).into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -259,22 +238,15 @@ impl LuaRuntimeState {
|
||||
|
||||
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,
|
||||
name: RString::from("Lua"),
|
||||
version: RString::from(env!("CARGO_PKG_VERSION")),
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn runtime_init(plugins_dir: RStr<'_>) -> RuntimeHandle {
|
||||
extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: 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");
|
||||
|
||||
state.discover_and_load(owlry_version.as_str());
|
||||
RuntimeHandle::from_box(state)
|
||||
}
|
||||
|
||||
@@ -346,8 +318,8 @@ mod tests {
|
||||
#[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);
|
||||
assert_eq!(info.name.as_str(), "Lua");
|
||||
assert!(!info.version.as_str().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -200,7 +200,7 @@ 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();
|
||||
fs::write(plugin_dir.join("main.lua"), "-- empty plugin").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -41,7 +41,7 @@ pub struct PluginInfo {
|
||||
#[serde(default = "default_owlry_version")]
|
||||
pub owlry_version: String,
|
||||
/// Entry point file (relative to plugin directory)
|
||||
#[serde(default = "default_entry")]
|
||||
#[serde(default = "default_entry", alias = "entry_point")]
|
||||
pub entry: String,
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ fn default_owlry_version() -> String {
|
||||
}
|
||||
|
||||
fn default_entry() -> String {
|
||||
"init.lua".to_string()
|
||||
"main.lua".to_string()
|
||||
}
|
||||
|
||||
/// What the plugin provides
|
||||
@@ -160,7 +160,7 @@ version = "1.0.0"
|
||||
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");
|
||||
assert_eq!(manifest.plugin.entry, "main.lua");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owlry-plugin-api"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -297,26 +297,24 @@ pub struct HostAPI {
|
||||
pub log_error: extern "C" fn(message: RStr<'_>),
|
||||
}
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
// Global host API pointer - set by the host when loading plugins
|
||||
static mut HOST_API: Option<&'static HostAPI> = None;
|
||||
static HOST_API: OnceLock<&'static HostAPI> = OnceLock::new();
|
||||
|
||||
/// 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);
|
||||
}
|
||||
let _ = HOST_API.set(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 }
|
||||
HOST_API.get().copied()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owlry-rune"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.90"
|
||||
description = "Rune scripting runtime for owlry plugins"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! This module provides the `owlry` module that Rune plugins can use.
|
||||
|
||||
use rune::{ContextError, Module};
|
||||
use rune::{Any, ContextError, Module};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use owlry_plugin_api::{PluginItem, RString};
|
||||
@@ -20,9 +20,9 @@ pub struct ProviderRegistration {
|
||||
|
||||
/// An item returned by a provider
|
||||
///
|
||||
/// Used for converting Rune plugin items to FFI format.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
/// Exposed to Rune scripts as `owlry::Item`.
|
||||
#[derive(Debug, Clone, Any)]
|
||||
#[rune(item = ::owlry)]
|
||||
pub struct Item {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -34,8 +34,42 @@ pub struct Item {
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Constructor exposed to Rune via #[rune::function]
|
||||
#[rune::function(path = Self::new)]
|
||||
pub fn rune_new(id: String, name: String, command: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
command,
|
||||
description: None,
|
||||
icon: None,
|
||||
terminal: false,
|
||||
keywords: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set description (builder pattern for Rune)
|
||||
#[rune::function]
|
||||
fn description(mut self, desc: String) -> Self {
|
||||
self.description = Some(desc);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set icon (builder pattern for Rune)
|
||||
#[rune::function]
|
||||
fn icon(mut self, icon: String) -> Self {
|
||||
self.icon = Some(icon);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set keywords (builder pattern for Rune)
|
||||
#[rune::function]
|
||||
fn keywords(mut self, keywords: Vec<String>) -> Self {
|
||||
self.keywords = keywords;
|
||||
self
|
||||
}
|
||||
|
||||
/// 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()),
|
||||
@@ -62,7 +96,14 @@ pub static REGISTRATIONS: Mutex<Vec<ProviderRegistration>> = Mutex::new(Vec::new
|
||||
pub fn module() -> Result<Module, ContextError> {
|
||||
let mut module = Module::with_crate("owlry")?;
|
||||
|
||||
// Register logging functions using builder pattern
|
||||
// Register Item type with constructor and builder methods
|
||||
module.ty::<Item>()?;
|
||||
module.function_meta(Item::rune_new)?;
|
||||
module.function_meta(Item::description)?;
|
||||
module.function_meta(Item::icon)?;
|
||||
module.function_meta(Item::keywords)?;
|
||||
|
||||
// Register logging functions
|
||||
module.function("log_info", log_info).build()?;
|
||||
module.function("log_debug", log_debug).build()?;
|
||||
module.function("log_warn", log_warn).build()?;
|
||||
|
||||
@@ -72,7 +72,7 @@ struct RuntimeState {
|
||||
#[repr(C)]
|
||||
pub struct RuneRuntimeVTable {
|
||||
pub info: extern "C" fn() -> RuntimeInfo,
|
||||
pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle,
|
||||
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
|
||||
pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<RuneProviderInfo>,
|
||||
pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
|
||||
pub query: extern "C" fn(
|
||||
@@ -94,8 +94,9 @@ extern "C" fn runtime_info() -> RuntimeInfo {
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn runtime_init(plugins_dir: RStr<'_>) -> RuntimeHandle {
|
||||
extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle {
|
||||
let _ = env_logger::try_init();
|
||||
let _version = owlry_version.as_str();
|
||||
|
||||
let plugins_dir = PathBuf::from(plugins_dir.as_str());
|
||||
log::info!(
|
||||
@@ -250,7 +251,7 @@ mod tests {
|
||||
let plugins_dir = temp.path().to_string_lossy();
|
||||
|
||||
// Initialize runtime
|
||||
let handle = runtime_init(RStr::from_str(&plugins_dir));
|
||||
let handle = runtime_init(RStr::from_str(&plugins_dir), RStr::from_str("1.0.0"));
|
||||
assert!(!handle.0.is_null());
|
||||
|
||||
// Get providers (should be empty with no plugins)
|
||||
|
||||
@@ -59,8 +59,20 @@ impl LoadedPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
// Collect registrations
|
||||
let registrations = api::get_registrations();
|
||||
// Collect registrations — from runtime API or from manifest [[providers]]
|
||||
let mut registrations = api::get_registrations();
|
||||
if registrations.is_empty() && !manifest.providers.is_empty() {
|
||||
for decl in &manifest.providers {
|
||||
registrations.push(ProviderRegistration {
|
||||
name: decl.id.clone(),
|
||||
display_name: decl.name.clone(),
|
||||
type_id: decl.type_id.clone().unwrap_or_else(|| decl.id.clone()),
|
||||
default_icon: decl.icon.clone().unwrap_or_else(|| "application-x-addon".to_string()),
|
||||
is_static: decl.provider_type != "dynamic",
|
||||
prefix: decl.prefix.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Loaded Rune plugin '{}' with {} provider(s)",
|
||||
@@ -92,16 +104,37 @@ impl LoadedPlugin {
|
||||
self.registrations.iter().any(|r| r.name == name)
|
||||
}
|
||||
|
||||
/// Refresh a static provider (stub for now)
|
||||
/// Refresh a static provider by calling the Rune `refresh()` function
|
||||
pub fn refresh_provider(&mut self, _name: &str) -> Result<Vec<PluginItem>, String> {
|
||||
// TODO: Implement provider refresh by calling Rune function
|
||||
Ok(Vec::new())
|
||||
let mut vm = create_vm(&self.context, self.unit.clone())
|
||||
.map_err(|e| format!("Failed to create VM: {}", e))?;
|
||||
|
||||
let output = vm
|
||||
.call(rune::Hash::type_hash(["refresh"]), ())
|
||||
.map_err(|e| format!("refresh() call failed: {}", e))?;
|
||||
|
||||
let items: Vec<crate::api::Item> = rune::from_value(output)
|
||||
.map_err(|e| format!("Failed to parse refresh() result: {}", e))?;
|
||||
|
||||
Ok(items.iter().map(|i| i.to_plugin_item()).collect())
|
||||
}
|
||||
|
||||
/// Query a dynamic provider (stub for now)
|
||||
pub fn query_provider(&mut self, _name: &str, _query: &str) -> Result<Vec<PluginItem>, String> {
|
||||
// TODO: Implement provider query by calling Rune function
|
||||
Ok(Vec::new())
|
||||
/// Query a dynamic provider by calling the Rune `query(q)` function
|
||||
pub fn query_provider(&mut self, _name: &str, query: &str) -> Result<Vec<PluginItem>, String> {
|
||||
let mut vm = create_vm(&self.context, self.unit.clone())
|
||||
.map_err(|e| format!("Failed to create VM: {}", e))?;
|
||||
|
||||
let output = vm
|
||||
.call(
|
||||
rune::Hash::type_hash(["query"]),
|
||||
(query.to_string(),),
|
||||
)
|
||||
.map_err(|e| format!("query() call failed: {}", e))?;
|
||||
|
||||
let items: Vec<crate::api::Item> = rune::from_value(output)
|
||||
.map_err(|e| format!("Failed to parse query() result: {}", e))?;
|
||||
|
||||
Ok(items.iter().map(|i| i.to_plugin_item()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,28 @@ pub struct PluginManifest {
|
||||
pub provides: PluginProvides,
|
||||
#[serde(default)]
|
||||
pub permissions: PluginPermissions,
|
||||
/// Provider declarations from [[providers]] sections
|
||||
#[serde(default)]
|
||||
pub providers: Vec<ProviderDecl>,
|
||||
}
|
||||
|
||||
/// A provider declared in [[providers]] section of plugin.toml
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ProviderDecl {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub prefix: Option<String>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default = "default_provider_type", rename = "type")]
|
||||
pub provider_type: String,
|
||||
#[serde(default)]
|
||||
pub type_id: Option<String>,
|
||||
}
|
||||
|
||||
fn default_provider_type() -> String {
|
||||
"static".to_string()
|
||||
}
|
||||
|
||||
/// Core plugin information
|
||||
@@ -25,7 +47,7 @@ pub struct PluginInfo {
|
||||
pub author: String,
|
||||
#[serde(default = "default_owlry_version")]
|
||||
pub owlry_version: String,
|
||||
#[serde(default = "default_entry")]
|
||||
#[serde(default = "default_entry", alias = "entry_point")]
|
||||
pub entry: String,
|
||||
}
|
||||
|
||||
@@ -34,7 +56,7 @@ fn default_owlry_version() -> String {
|
||||
}
|
||||
|
||||
fn default_entry() -> String {
|
||||
"init.rn".to_string()
|
||||
"main.rn".to_string()
|
||||
}
|
||||
|
||||
/// What the plugin provides
|
||||
@@ -128,7 +150,7 @@ 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");
|
||||
assert_eq!(manifest.plugin.entry, "main.rn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owlry"
|
||||
version = "1.0.0"
|
||||
version = "1.0.6"
|
||||
edition = "2024"
|
||||
rust-version = "1.90"
|
||||
description = "A lightweight, owl-themed application launcher for Wayland"
|
||||
@@ -46,6 +46,9 @@ dirs = "5"
|
||||
# Semantic versioning (needed by plugin commands)
|
||||
semver = "1"
|
||||
|
||||
# Async oneshot channel (background thread -> main loop)
|
||||
futures-channel = "0.3"
|
||||
|
||||
[build-dependencies]
|
||||
# GResource compilation for bundled icons
|
||||
glib-build-tools = "0.20"
|
||||
|
||||
@@ -69,7 +69,7 @@ impl OwlryApp {
|
||||
match CoreClient::connect_or_start() {
|
||||
Ok(client) => {
|
||||
info!("Connected to owlry-core daemon");
|
||||
SearchBackend::Daemon(client)
|
||||
SearchBackend::Daemon(crate::backend::DaemonHandle::new(client))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
@@ -135,6 +135,9 @@ impl OwlryApp {
|
||||
Self::load_css(&config.borrow());
|
||||
|
||||
window.present();
|
||||
|
||||
// Populate results AFTER present() so the window appears immediately
|
||||
window.schedule_initial_results();
|
||||
}
|
||||
|
||||
/// Create a local backend as fallback when daemon is unavailable.
|
||||
@@ -182,16 +185,25 @@ impl OwlryApp {
|
||||
}
|
||||
|
||||
fn setup_icon_theme() {
|
||||
// Ensure we have icon fallbacks for weather/media icons
|
||||
// These may not exist in all icon themes
|
||||
if let Some(display) = gtk4::gdk::Display::default() {
|
||||
let icon_theme = gtk4::IconTheme::for_display(&display);
|
||||
|
||||
// Add Adwaita as fallback search path (has weather and media icons)
|
||||
icon_theme.add_search_path("/usr/share/icons/Adwaita");
|
||||
icon_theme.add_search_path("/usr/share/icons/breeze");
|
||||
// If the system icon theme doesn't exist on disk (e.g., set in
|
||||
// gsettings but not installed), GTK falls back to hicolor which
|
||||
// has almost no icons. Detect this and use Adwaita instead.
|
||||
let theme_name = icon_theme.theme_name();
|
||||
let theme_exists = icon_theme
|
||||
.search_path()
|
||||
.iter()
|
||||
.any(|p| p.join(theme_name.as_str()).is_dir());
|
||||
|
||||
debug!("Icon theme search paths configured with Adwaita/breeze fallbacks");
|
||||
if !theme_exists && theme_name != "hicolor" && theme_name != "Adwaita" {
|
||||
info!(
|
||||
"Icon theme '{}' not found on disk, falling back to Adwaita",
|
||||
theme_name
|
||||
);
|
||||
icon_theme.set_theme_name(Some("Adwaita"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,87 @@ use owlry_core::data::FrecencyStore;
|
||||
use owlry_core::filter::ProviderFilter;
|
||||
use owlry_core::ipc::ResultItem;
|
||||
use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Parameters needed to run a search query on a background thread.
|
||||
pub struct QueryParams {
|
||||
pub query: String,
|
||||
#[allow(dead_code)]
|
||||
pub max_results: usize,
|
||||
pub modes: Option<Vec<String>>,
|
||||
pub tag_filter: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of an async search, sent back to the main thread.
|
||||
pub struct QueryResult {
|
||||
#[allow(dead_code)]
|
||||
pub query: String,
|
||||
pub items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
/// Thread-safe handle to the daemon IPC connection.
|
||||
pub struct DaemonHandle {
|
||||
pub(crate) client: Arc<Mutex<CoreClient>>,
|
||||
}
|
||||
|
||||
impl DaemonHandle {
|
||||
pub fn new(client: CoreClient) -> Self {
|
||||
Self {
|
||||
client: Arc::new(Mutex::new(client)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch an IPC query on a background thread.
|
||||
///
|
||||
/// Returns a `futures_channel::oneshot::Receiver` that resolves with
|
||||
/// the `QueryResult` once the background thread completes IPC. The
|
||||
/// caller should `.await` it inside `glib::spawn_future_local` to
|
||||
/// process results on the GTK main thread without `Send` constraints.
|
||||
pub fn query_async(
|
||||
&self,
|
||||
params: QueryParams,
|
||||
) -> futures_channel::oneshot::Receiver<QueryResult> {
|
||||
let (tx, rx) = futures_channel::oneshot::channel();
|
||||
let client = Arc::clone(&self.client);
|
||||
let query_for_result = params.query.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let items = match client.lock() {
|
||||
Ok(mut c) => {
|
||||
let effective_query = if let Some(ref tag) = params.tag_filter {
|
||||
format!(":tag:{} {}", tag, params.query)
|
||||
} else {
|
||||
params.query
|
||||
};
|
||||
match c.query(&effective_query, params.modes) {
|
||||
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
|
||||
Err(e) => {
|
||||
warn!("IPC query failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to lock daemon client: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let _ = tx.send(QueryResult {
|
||||
query: query_for_result,
|
||||
items,
|
||||
});
|
||||
});
|
||||
|
||||
rx
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend for search operations. Wraps either an IPC client (daemon mode)
|
||||
/// or a local ProviderManager (dmenu mode).
|
||||
pub enum SearchBackend {
|
||||
/// IPC client connected to owlry-core daemon
|
||||
Daemon(CoreClient),
|
||||
Daemon(DaemonHandle),
|
||||
/// Direct local provider manager (dmenu mode only)
|
||||
Local {
|
||||
providers: Box<ProviderManager>,
|
||||
@@ -24,6 +99,22 @@ pub enum SearchBackend {
|
||||
}
|
||||
|
||||
impl SearchBackend {
|
||||
/// Build the modes parameter from a ProviderFilter.
|
||||
/// When accept_all, returns None so the daemon doesn't restrict to a specific set
|
||||
/// (otherwise dynamically loaded plugin types would be filtered out).
|
||||
fn build_modes_param(filter: &ProviderFilter) -> Option<Vec<String>> {
|
||||
if filter.is_accept_all() {
|
||||
None
|
||||
} else {
|
||||
let modes: Vec<String> = filter
|
||||
.enabled_providers()
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
if modes.is_empty() { None } else { Some(modes) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Search for items matching the query.
|
||||
///
|
||||
/// In daemon mode, sends query over IPC. The modes list is derived from
|
||||
@@ -38,19 +129,18 @@ impl SearchBackend {
|
||||
config: &Config,
|
||||
) -> Vec<LaunchItem> {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => {
|
||||
let modes: Vec<String> = filter
|
||||
.enabled_providers()
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
|
||||
let modes_param = if modes.is_empty() { None } else { Some(modes) };
|
||||
|
||||
match client.query(query, modes_param) {
|
||||
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
|
||||
SearchBackend::Daemon(handle) => {
|
||||
let modes_param = Self::build_modes_param(filter);
|
||||
match handle.client.lock() {
|
||||
Ok(mut client) => match client.query(query, modes_param) {
|
||||
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
|
||||
Err(e) => {
|
||||
warn!("IPC query failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("IPC query failed: {}", e);
|
||||
warn!("Failed to lock daemon client: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -96,27 +186,24 @@ impl SearchBackend {
|
||||
tag_filter: Option<&str>,
|
||||
) -> Vec<LaunchItem> {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => {
|
||||
// Daemon doesn't support tag filtering in IPC yet — pass query as-is.
|
||||
// If there's a tag filter, prepend it so the daemon can handle it.
|
||||
SearchBackend::Daemon(handle) => {
|
||||
let effective_query = if let Some(tag) = tag_filter {
|
||||
format!(":tag:{} {}", tag, query)
|
||||
} else {
|
||||
query.to_string()
|
||||
};
|
||||
|
||||
let modes: Vec<String> = filter
|
||||
.enabled_providers()
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
|
||||
let modes_param = if modes.is_empty() { None } else { Some(modes) };
|
||||
|
||||
match client.query(&effective_query, modes_param) {
|
||||
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
|
||||
let modes_param = Self::build_modes_param(filter);
|
||||
match handle.client.lock() {
|
||||
Ok(mut client) => match client.query(&effective_query, modes_param) {
|
||||
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
|
||||
Err(e) => {
|
||||
warn!("IPC query failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("IPC query failed: {}", e);
|
||||
warn!("Failed to lock daemon client: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -152,13 +239,43 @@ impl SearchBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch async search (daemon mode only).
|
||||
/// Returns `Some(Receiver)` if dispatched, `None` for local mode.
|
||||
pub fn query_async(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &ProviderFilter,
|
||||
_config: &Config,
|
||||
tag_filter: Option<&str>,
|
||||
) -> Option<futures_channel::oneshot::Receiver<QueryResult>> {
|
||||
match self {
|
||||
SearchBackend::Daemon(handle) => {
|
||||
let params = QueryParams {
|
||||
query: query.to_string(),
|
||||
max_results,
|
||||
modes: Self::build_modes_param(filter),
|
||||
tag_filter: tag_filter.map(|s| s.to_string()),
|
||||
};
|
||||
Some(handle.query_async(params))
|
||||
}
|
||||
SearchBackend::Local { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a plugin action command. Returns true if handled.
|
||||
pub fn execute_plugin_action(&mut self, command: &str) -> bool {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => match client.plugin_action(command) {
|
||||
Ok(handled) => handled,
|
||||
SearchBackend::Daemon(handle) => match handle.client.lock() {
|
||||
Ok(mut client) => match client.plugin_action(command) {
|
||||
Ok(handled) => handled,
|
||||
Err(e) => {
|
||||
warn!("IPC plugin_action failed: {}", e);
|
||||
false
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("IPC plugin_action failed: {}", e);
|
||||
warn!("Failed to lock daemon client: {}", e);
|
||||
false
|
||||
}
|
||||
},
|
||||
@@ -175,15 +292,21 @@ impl SearchBackend {
|
||||
display_name: &str,
|
||||
) -> Option<(String, Vec<LaunchItem>)> {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => match client.submenu(plugin_id, data) {
|
||||
Ok(items) if !items.is_empty() => {
|
||||
let actions: Vec<LaunchItem> =
|
||||
items.into_iter().map(result_to_launch_item).collect();
|
||||
Some((display_name.to_string(), actions))
|
||||
}
|
||||
Ok(_) => None,
|
||||
SearchBackend::Daemon(handle) => match handle.client.lock() {
|
||||
Ok(mut client) => match client.submenu(plugin_id, data) {
|
||||
Ok(items) if !items.is_empty() => {
|
||||
let actions: Vec<LaunchItem> =
|
||||
items.into_iter().map(result_to_launch_item).collect();
|
||||
Some((display_name.to_string(), actions))
|
||||
}
|
||||
Ok(_) => None,
|
||||
Err(e) => {
|
||||
warn!("IPC submenu query failed: {}", e);
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("IPC submenu query failed: {}", e);
|
||||
warn!("Failed to lock daemon client: {}", e);
|
||||
None
|
||||
}
|
||||
},
|
||||
@@ -196,9 +319,13 @@ impl SearchBackend {
|
||||
/// Record a launch event for frecency tracking.
|
||||
pub fn record_launch(&mut self, item_id: &str, provider: &str) {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => {
|
||||
if let Err(e) = client.launch(item_id, provider) {
|
||||
warn!("IPC launch notification failed: {}", e);
|
||||
SearchBackend::Daemon(handle) => {
|
||||
if let Ok(mut client) = handle.client.lock() {
|
||||
if let Err(e) = client.launch(item_id, provider) {
|
||||
warn!("IPC launch notification failed: {}", e);
|
||||
}
|
||||
} else {
|
||||
warn!("Failed to lock daemon client for launch");
|
||||
}
|
||||
}
|
||||
SearchBackend::Local { frecency, .. } => {
|
||||
@@ -226,10 +353,16 @@ impl SearchBackend {
|
||||
#[allow(dead_code)]
|
||||
pub fn available_provider_ids(&mut self) -> Vec<String> {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => match client.providers() {
|
||||
Ok(descs) => descs.into_iter().map(|d| d.id).collect(),
|
||||
SearchBackend::Daemon(handle) => match handle.client.lock() {
|
||||
Ok(mut client) => match client.providers() {
|
||||
Ok(descs) => descs.into_iter().map(|d| d.id).collect(),
|
||||
Err(e) => {
|
||||
warn!("IPC providers query failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("IPC providers query failed: {}", e);
|
||||
warn!("Failed to lock daemon client: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::time::Duration;
|
||||
|
||||
use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem};
|
||||
|
||||
/// IPC client that connects to the owlry-core daemon Unix socket
|
||||
/// IPC client that connects to the owlryd daemon Unix socket
|
||||
/// and provides typed methods for all IPC operations.
|
||||
pub struct CoreClient {
|
||||
stream: UnixStream,
|
||||
@@ -38,15 +38,15 @@ impl CoreClient {
|
||||
|
||||
// Socket not available — try to start the daemon.
|
||||
let status = std::process::Command::new("systemctl")
|
||||
.args(["--user", "start", "owlry-core"])
|
||||
.args(["--user", "start", "owlryd"])
|
||||
.status()
|
||||
.map_err(|e| {
|
||||
io::Error::other(format!("failed to start owlry-core via systemd: {e}"))
|
||||
io::Error::other(format!("failed to start owlryd via systemd: {e}"))
|
||||
})?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"systemctl --user start owlry-core exited with status {}",
|
||||
"systemctl --user start owlryd exited with status {}",
|
||||
status
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -1114,10 +1114,14 @@ fn execute_plugin_command(
|
||||
|
||||
// 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::Lua => {
|
||||
let owlry_version = env!("CARGO_PKG_VERSION");
|
||||
LoadedRuntime::load_lua(plugin_path.parent().unwrap_or(plugin_path), owlry_version)
|
||||
.map_err(|e| format!("Failed to load Lua runtime: {}", e))?
|
||||
}
|
||||
PluginRuntime::Rune => {
|
||||
LoadedRuntime::load_rune(plugin_path.parent().unwrap_or(plugin_path))
|
||||
let owlry_version = env!("CARGO_PKG_VERSION");
|
||||
LoadedRuntime::load_rune(plugin_path.parent().unwrap_or(plugin_path), owlry_version)
|
||||
.map_err(|e| format!("Failed to load Rune runtime: {}", e))?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
background-color: var(--owlry-bg, @theme_bg_color);
|
||||
border-radius: var(--owlry-border-radius, 12px);
|
||||
border: 1px solid var(--owlry-border, @borders);
|
||||
box-shadow: var(--owlry-shadow, none);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@@ -56,6 +57,16 @@
|
||||
color: var(--owlry-accent-bright, @theme_selected_fg_color);
|
||||
}
|
||||
|
||||
/* Highlighted result row (exact match or auto-detected plugin result) */
|
||||
.owlry-result-highlight {
|
||||
background-color: alpha(var(--owlry-accent, @theme_selected_bg_color), 0.08);
|
||||
border-left: 3px solid var(--owlry-accent, @theme_selected_bg_color);
|
||||
}
|
||||
|
||||
.owlry-result-highlight:selected {
|
||||
border-left: 3px solid var(--owlry-accent-bright, @theme_selected_fg_color);
|
||||
}
|
||||
|
||||
/* Result icon */
|
||||
.owlry-result-icon {
|
||||
color: var(--owlry-text, @theme_fg_color);
|
||||
|
||||
@@ -31,8 +31,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(26, 27, 38, 0.95);
|
||||
border: 1px solid rgba(65, 72, 104, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(224, 175, 104, 0.1);
|
||||
}
|
||||
|
||||
/* Search entry */
|
||||
|
||||
@@ -42,6 +42,8 @@ struct LazyLoadState {
|
||||
all_results: Vec<LaunchItem>,
|
||||
/// Number of items currently displayed
|
||||
displayed_count: usize,
|
||||
/// The query that produced these results (for highlighting in lazy-loaded batches)
|
||||
query: String,
|
||||
}
|
||||
|
||||
/// Number of items to display initially and per batch
|
||||
@@ -224,7 +226,6 @@ impl MainWindow {
|
||||
|
||||
main_window.setup_signals();
|
||||
main_window.setup_lazy_loading();
|
||||
main_window.update_results("");
|
||||
|
||||
// Ensure search entry has focus when window is shown
|
||||
main_window.search_entry.grab_focus();
|
||||
@@ -240,36 +241,18 @@ impl MainWindow {
|
||||
search_entry_for_refresh.emit_by_name::<()>("changed", &[]);
|
||||
});
|
||||
|
||||
// Set up periodic widget auto-refresh (every 5 seconds) — local backend only
|
||||
// In daemon mode, the daemon handles widget refresh and results come via IPC
|
||||
if main_window.is_dmenu_mode {
|
||||
// dmenu typically has no widgets, but this is harmless
|
||||
// Periodic widget refresh — local backend only.
|
||||
// In daemon mode, the daemon handles widget refresh internally;
|
||||
// the UI gets updated data on the next user-initiated search.
|
||||
// We do NOT re-query in daemon mode because it resets the user's
|
||||
// scroll position and selection.
|
||||
if !matches!(&*main_window.backend.borrow(), SearchBackend::Daemon(_)) {
|
||||
let backend_for_auto = main_window.backend.clone();
|
||||
gtk4::glib::timeout_add_local(std::time::Duration::from_secs(5), move || {
|
||||
backend_for_auto.borrow_mut().refresh_widgets();
|
||||
gtk4::glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
let backend_for_auto = main_window.backend.clone();
|
||||
let current_results_for_auto = main_window.current_results.clone();
|
||||
let submenu_state_for_auto = main_window.submenu_state.clone();
|
||||
let search_entry_for_auto = main_window.search_entry.clone();
|
||||
gtk4::glib::timeout_add_local(std::time::Duration::from_secs(5), move || {
|
||||
let in_submenu = submenu_state_for_auto.borrow().active;
|
||||
|
||||
// For local backend: refresh widgets (daemon handles this itself)
|
||||
backend_for_auto.borrow_mut().refresh_widgets();
|
||||
|
||||
// For daemon backend: re-query to get updated widget data
|
||||
if !in_submenu {
|
||||
if let SearchBackend::Daemon(_) = &*backend_for_auto.borrow() {
|
||||
// Trigger a re-search to pick up updated widget items from daemon
|
||||
search_entry_for_auto.emit_by_name::<()>("changed", &[]);
|
||||
} else {
|
||||
// Local backend: update widget items in-place (legacy behavior)
|
||||
// This path is only hit in dmenu mode which doesn't have widgets,
|
||||
// but keep it for completeness.
|
||||
let _results = current_results_for_auto.borrow();
|
||||
// No-op for local mode without widget access
|
||||
}
|
||||
}
|
||||
gtk4::glib::ControlFlow::Continue
|
||||
});
|
||||
|
||||
main_window
|
||||
}
|
||||
@@ -458,7 +441,12 @@ impl MainWindow {
|
||||
}
|
||||
|
||||
/// Scroll the given row into view within the scrolled window
|
||||
fn scroll_to_row(scrolled: &ScrolledWindow, results_list: &ListBox, row: &ListBoxRow) {
|
||||
fn scroll_to_row(
|
||||
scrolled: &ScrolledWindow,
|
||||
results_list: &ListBox,
|
||||
row: &ListBoxRow,
|
||||
lazy_state: &Rc<RefCell<LazyLoadState>>,
|
||||
) {
|
||||
let vadj = scrolled.vadjustment();
|
||||
|
||||
let row_index = row.index();
|
||||
@@ -470,15 +458,7 @@ impl MainWindow {
|
||||
let current_scroll = vadj.value();
|
||||
|
||||
let list_height = results_list.height() as f64;
|
||||
let row_count = {
|
||||
let mut count = 0;
|
||||
let mut child = results_list.first_child();
|
||||
while child.is_some() {
|
||||
count += 1;
|
||||
child = child.and_then(|c| c.next_sibling());
|
||||
}
|
||||
count.max(1) as f64
|
||||
};
|
||||
let row_count = lazy_state.borrow().displayed_count.max(1) as f64;
|
||||
|
||||
let row_height = list_height / row_count;
|
||||
let row_top = row_index as f64 * row_height;
|
||||
@@ -532,7 +512,7 @@ impl MainWindow {
|
||||
}
|
||||
|
||||
for item in &actions {
|
||||
let row = ResultRow::new(item);
|
||||
let row = ResultRow::new(item, "");
|
||||
results_list.append(&row);
|
||||
}
|
||||
|
||||
@@ -614,7 +594,7 @@ impl MainWindow {
|
||||
}
|
||||
|
||||
for item in &filtered {
|
||||
let row = ResultRow::new(item);
|
||||
let row = ResultRow::new(item, "");
|
||||
results_list.append(&row);
|
||||
}
|
||||
|
||||
@@ -675,6 +655,11 @@ impl MainWindow {
|
||||
let filter = filter.clone();
|
||||
let lazy_state = lazy_state.clone();
|
||||
let debounce_source_for_closure = debounce_source.clone();
|
||||
let query_str = parsed.query.clone();
|
||||
let tag = parsed.tag_filter.clone();
|
||||
// Capture the raw entry text at dispatch time for staleness detection.
|
||||
let raw_text_at_dispatch = entry.text().to_string();
|
||||
let search_entry_for_stale = search_entry_for_change.clone();
|
||||
|
||||
// Schedule debounced search
|
||||
let source_id = gtk4::glib::timeout_add_local_once(
|
||||
@@ -687,40 +672,95 @@ impl MainWindow {
|
||||
let max_results = cfg.general.max_results;
|
||||
drop(cfg);
|
||||
|
||||
let results = backend.borrow_mut().search_with_tag(
|
||||
&parsed.query,
|
||||
max_results,
|
||||
&filter.borrow(),
|
||||
&config.borrow(),
|
||||
parsed.tag_filter.as_deref(),
|
||||
);
|
||||
// Try async path (daemon mode)
|
||||
let receiver = {
|
||||
let be = backend.borrow();
|
||||
let f = filter.borrow();
|
||||
let c = config.borrow();
|
||||
be.query_async(
|
||||
&query_str,
|
||||
max_results,
|
||||
&f,
|
||||
&c,
|
||||
tag.as_deref(),
|
||||
)
|
||||
};
|
||||
|
||||
// Clear existing results
|
||||
while let Some(child) = results_list.first_child() {
|
||||
results_list.remove(&child);
|
||||
}
|
||||
if let Some(rx) = receiver {
|
||||
// Daemon mode: results arrive asynchronously on the main loop.
|
||||
// spawn_future_local runs the async block on the GTK main
|
||||
// thread, so non-Send types (Rc, GTK widgets) are fine.
|
||||
let results_list_cb = results_list.clone();
|
||||
let current_results_cb = current_results.clone();
|
||||
let lazy_state_cb = lazy_state.clone();
|
||||
let query_for_highlight = query_str.clone();
|
||||
|
||||
// Lazy loading: store all results but only display initial batch
|
||||
let initial_count = INITIAL_RESULTS.min(results.len());
|
||||
{
|
||||
gtk4::glib::spawn_future_local(async move {
|
||||
if let Ok(result) = rx.await {
|
||||
// Discard stale results: the user has typed something new
|
||||
// since this query was dispatched.
|
||||
if search_entry_for_stale.text().as_str() != raw_text_at_dispatch {
|
||||
return;
|
||||
}
|
||||
while let Some(child) = results_list_cb.first_child() {
|
||||
results_list_cb.remove(&child);
|
||||
}
|
||||
|
||||
let items = result.items;
|
||||
let initial_count =
|
||||
INITIAL_RESULTS.min(items.len());
|
||||
|
||||
for item in items.iter().take(initial_count) {
|
||||
let row = ResultRow::new(item, &query_for_highlight);
|
||||
results_list_cb.append(&row);
|
||||
}
|
||||
|
||||
if let Some(first_row) =
|
||||
results_list_cb.row_at_index(0)
|
||||
{
|
||||
results_list_cb.select_row(Some(&first_row));
|
||||
}
|
||||
|
||||
*current_results_cb.borrow_mut() =
|
||||
items[..initial_count].to_vec();
|
||||
let mut lazy = lazy_state_cb.borrow_mut();
|
||||
lazy.all_results = items;
|
||||
lazy.displayed_count = initial_count;
|
||||
lazy.query = query_for_highlight;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Local mode (dmenu): synchronous search
|
||||
let results = backend.borrow_mut().search_with_tag(
|
||||
&query_str,
|
||||
max_results,
|
||||
&filter.borrow(),
|
||||
&config.borrow(),
|
||||
tag.as_deref(),
|
||||
);
|
||||
|
||||
while let Some(child) = results_list.first_child() {
|
||||
results_list.remove(&child);
|
||||
}
|
||||
|
||||
let initial_count = INITIAL_RESULTS.min(results.len());
|
||||
|
||||
for item in results.iter().take(initial_count) {
|
||||
let row = ResultRow::new(item, &query_str);
|
||||
results_list.append(&row);
|
||||
}
|
||||
|
||||
if let Some(first_row) = results_list.row_at_index(0) {
|
||||
results_list.select_row(Some(&first_row));
|
||||
}
|
||||
|
||||
*current_results.borrow_mut() =
|
||||
results[..initial_count].to_vec();
|
||||
let mut lazy = lazy_state.borrow_mut();
|
||||
lazy.all_results = results.clone();
|
||||
lazy.all_results = results;
|
||||
lazy.query = query_str;
|
||||
lazy.displayed_count = initial_count;
|
||||
}
|
||||
|
||||
// Display only initial batch
|
||||
for item in results.iter().take(initial_count) {
|
||||
let row = ResultRow::new(item);
|
||||
results_list.append(&row);
|
||||
}
|
||||
|
||||
if let Some(first_row) = results_list.row_at_index(0) {
|
||||
results_list.select_row(Some(&first_row));
|
||||
}
|
||||
|
||||
// current_results holds only what's displayed (for selection/activation)
|
||||
*current_results.borrow_mut() =
|
||||
results.into_iter().take(initial_count).collect();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -856,6 +896,7 @@ impl MainWindow {
|
||||
let submenu_state = self.submenu_state.clone();
|
||||
let tab_order = self.tab_order.clone();
|
||||
let is_dmenu_mode = self.is_dmenu_mode;
|
||||
let lazy_state_for_keys = self.lazy_state.clone();
|
||||
|
||||
key_controller.connect_key_pressed(move |_, key, _, modifiers| {
|
||||
let ctrl = modifiers.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||
@@ -919,7 +960,7 @@ impl MainWindow {
|
||||
let next_index = current.index() + 1;
|
||||
if let Some(next_row) = results_list.row_at_index(next_index) {
|
||||
results_list.select_row(Some(&next_row));
|
||||
Self::scroll_to_row(&scrolled, &results_list, &next_row);
|
||||
Self::scroll_to_row(&scrolled, &results_list, &next_row, &lazy_state_for_keys);
|
||||
}
|
||||
}
|
||||
gtk4::glib::Propagation::Stop
|
||||
@@ -931,7 +972,7 @@ impl MainWindow {
|
||||
&& 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);
|
||||
Self::scroll_to_row(&scrolled, &results_list, &prev_row, &lazy_state_for_keys);
|
||||
}
|
||||
}
|
||||
gtk4::glib::Propagation::Stop
|
||||
@@ -1183,43 +1224,49 @@ impl MainWindow {
|
||||
entry.emit_by_name::<()>("changed", &[]);
|
||||
}
|
||||
|
||||
fn update_results(&self, query: &str) {
|
||||
let cfg = self.config.borrow();
|
||||
let max_results = cfg.general.max_results;
|
||||
drop(cfg);
|
||||
/// Schedule initial results population via idle callback.
|
||||
/// Call this AFTER `window.present()` so the window appears immediately.
|
||||
pub fn schedule_initial_results(&self) {
|
||||
let backend = self.backend.clone();
|
||||
let results_list = self.results_list.clone();
|
||||
let config = self.config.clone();
|
||||
let filter = self.filter.clone();
|
||||
let current_results = self.current_results.clone();
|
||||
let lazy_state = self.lazy_state.clone();
|
||||
|
||||
let results = self.backend.borrow_mut().search(
|
||||
query,
|
||||
max_results,
|
||||
&self.filter.borrow(),
|
||||
&self.config.borrow(),
|
||||
);
|
||||
gtk4::glib::idle_add_local_once(move || {
|
||||
let cfg = config.borrow();
|
||||
let max_results = cfg.general.max_results;
|
||||
drop(cfg);
|
||||
|
||||
// Clear existing results
|
||||
while let Some(child) = self.results_list.first_child() {
|
||||
self.results_list.remove(&child);
|
||||
}
|
||||
let results = backend.borrow_mut().search(
|
||||
"",
|
||||
max_results,
|
||||
&filter.borrow(),
|
||||
&config.borrow(),
|
||||
);
|
||||
|
||||
// Store all results for lazy loading
|
||||
let initial_count = INITIAL_RESULTS.min(results.len());
|
||||
{
|
||||
let mut lazy = self.lazy_state.borrow_mut();
|
||||
lazy.all_results = results.clone();
|
||||
// Clear existing results
|
||||
while let Some(child) = results_list.first_child() {
|
||||
results_list.remove(&child);
|
||||
}
|
||||
|
||||
let initial_count = INITIAL_RESULTS.min(results.len());
|
||||
|
||||
for item in results.iter().take(initial_count) {
|
||||
let row = ResultRow::new(item, "");
|
||||
results_list.append(&row);
|
||||
}
|
||||
|
||||
if let Some(first_row) = results_list.row_at_index(0) {
|
||||
results_list.select_row(Some(&first_row));
|
||||
}
|
||||
|
||||
*current_results.borrow_mut() = results[..initial_count].to_vec();
|
||||
let mut lazy = lazy_state.borrow_mut();
|
||||
lazy.all_results = results;
|
||||
lazy.displayed_count = initial_count;
|
||||
}
|
||||
|
||||
// Display initial batch only
|
||||
for item in results.iter().take(initial_count) {
|
||||
let row = ResultRow::new(item);
|
||||
self.results_list.append(&row);
|
||||
}
|
||||
|
||||
if let Some(first_row) = self.results_list.row_at_index(0) {
|
||||
self.results_list.select_row(Some(&first_row));
|
||||
}
|
||||
|
||||
// current_results holds what's currently displayed
|
||||
*self.current_results.borrow_mut() = results.into_iter().take(initial_count).collect();
|
||||
});
|
||||
}
|
||||
|
||||
/// Set up lazy loading scroll detection
|
||||
@@ -1276,8 +1323,9 @@ impl MainWindow {
|
||||
if displayed < all_count {
|
||||
// Load next batch
|
||||
let new_end = (displayed + LOAD_MORE_BATCH).min(all_count);
|
||||
let query = lazy.query.clone();
|
||||
for item in lazy.all_results[displayed..new_end].iter() {
|
||||
let row = ResultRow::new(item);
|
||||
let row = ResultRow::new(item, &query);
|
||||
results_list.append(&row);
|
||||
}
|
||||
lazy.displayed_count = new_end;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget};
|
||||
use owlry_core::providers::LaunchItem;
|
||||
use owlry_core::providers::{LaunchItem, ProviderType};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct ResultRow {
|
||||
@@ -18,9 +18,31 @@ fn is_emoji_icon(s: &str) -> bool {
|
||||
!first_char.is_ascii() && s.chars().count() <= 8
|
||||
}
|
||||
|
||||
/// Check if this item should be highlighted based on the query.
|
||||
/// Highlighted when:
|
||||
/// - Item is from an auto-detecting plugin (calculator, converter) that parsed
|
||||
/// the query into a result — these produce direct answers, not search results
|
||||
/// - Item name exactly matches the query (case-insensitive)
|
||||
fn should_highlight(item: &LaunchItem, query: &str) -> bool {
|
||||
if query.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exact name match (case-insensitive)
|
||||
if item.name.eq_ignore_ascii_case(query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Auto-detect plugins that produce direct answers (not search tools)
|
||||
matches!(
|
||||
&item.provider,
|
||||
ProviderType::Plugin(id) if matches!(id.as_str(), "calc" | "conv")
|
||||
)
|
||||
}
|
||||
|
||||
impl ResultRow {
|
||||
#[allow(clippy::new_ret_no_self)]
|
||||
pub fn new(item: &LaunchItem) -> ListBoxRow {
|
||||
pub fn new(item: &LaunchItem, query: &str) -> ListBoxRow {
|
||||
let row = ListBoxRow::builder()
|
||||
.selectable(true)
|
||||
.activatable(true)
|
||||
@@ -28,6 +50,10 @@ impl ResultRow {
|
||||
|
||||
row.add_css_class("owlry-result-row");
|
||||
|
||||
if should_highlight(item, query) {
|
||||
row.add_css_class("owlry-result-highlight");
|
||||
}
|
||||
|
||||
let hbox = GtkBox::builder()
|
||||
.orientation(Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
|
||||
@@ -77,8 +77,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(5, 5, 5, 0.98);
|
||||
border: 1px solid rgba(38, 38, 38, 0.8);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8),
|
||||
0 0 0 1px rgba(255, 0, 68, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(30, 30, 46, 0.95);
|
||||
border: 1px solid rgba(69, 71, 90, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(203, 166, 247, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(40, 42, 54, 0.95);
|
||||
border: 1px solid rgba(98, 114, 164, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(189, 147, 249, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(40, 40, 40, 0.95);
|
||||
border: 1px solid rgba(80, 73, 69, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(254, 128, 25, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(46, 52, 64, 0.95);
|
||||
border: 1px solid rgba(76, 86, 106, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px rgba(136, 192, 208, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(40, 44, 52, 0.95);
|
||||
border: 1px solid rgba(24, 26, 31, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(97, 175, 239, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -33,8 +33,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(26, 27, 38, 0.95);
|
||||
border: 1px solid rgba(65, 72, 104, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(224, 175, 104, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(25, 23, 36, 0.95);
|
||||
border: 1px solid rgba(38, 35, 58, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(196, 167, 231, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(0, 43, 54, 0.95);
|
||||
border: 1px solid rgba(88, 110, 117, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(38, 139, 210, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.owlry-main {
|
||||
background-color: rgba(26, 27, 38, 0.95);
|
||||
border: 1px solid rgba(65, 72, 104, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(122, 162, 247, 0.1);
|
||||
}
|
||||
|
||||
.owlry-search {
|
||||
|
||||
1225
docs/superpowers/plans/2026-03-28-builtin-providers.md
Normal file
1225
docs/superpowers/plans/2026-03-28-builtin-providers.md
Normal file
File diff suppressed because it is too large
Load Diff
116
docs/superpowers/specs/2026-03-28-builtin-providers-design.md
Normal file
116
docs/superpowers/specs/2026-03-28-builtin-providers-design.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Built-in Providers Migration — Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Move calculator, converter, and system from external `.so` plugins (owlry-plugins repo) to native providers compiled into `owlry-core`. Remove 3 plugin AUR packages (transitional), 4 meta AUR packages (already deleted). Update READMEs for both repos.
|
||||
|
||||
## Architecture
|
||||
|
||||
The 3 plugins currently use the FFI plugin API (`PluginVTable`, `PluginItem`, etc.) and are loaded as `.so` files by `NativePluginLoader`. As built-in providers, they become native Rust modules inside `owlry-core/src/providers/` implementing the existing `Provider` trait — same as `ApplicationProvider` and `CommandProvider`.
|
||||
|
||||
No changes to the plugin system itself. External plugins continue to work via `.so` loading.
|
||||
|
||||
## Components
|
||||
|
||||
### New modules in owlry-core
|
||||
|
||||
- `providers/calculator.rs` — port of owlry-plugin-calculator (231 lines, depends on `meval`)
|
||||
- `providers/converter/mod.rs` — port of owlry-plugin-converter entry point
|
||||
- `providers/converter/parser.rs` — query parsing (235 lines, no new deps)
|
||||
- `providers/converter/units.rs` — unit definitions + conversion (944 lines, no new deps)
|
||||
- `providers/converter/currency.rs` — ECB rate fetching (313 lines, depends on `reqwest` blocking + `dirs` + `serde`)
|
||||
- `providers/system.rs` — port of owlry-plugin-system (257 lines, no new deps)
|
||||
|
||||
### New owlry-core dependencies
|
||||
|
||||
- `meval` — math expression evaluation (currently optional behind `lua` feature, make required)
|
||||
- `reqwest` with `blocking` feature — ECB currency rate fetching (currently optional behind `lua`, make required)
|
||||
- `dirs` — already a dependency
|
||||
- `serde`/`serde_json` — already dependencies
|
||||
|
||||
### Modified files
|
||||
|
||||
- `owlry-core/src/providers/mod.rs` — register the 3 new providers in `ProviderManager`, honor config toggles, classify calculator+converter as dynamic providers
|
||||
- `owlry-core/Cargo.toml` — move `meval` and `reqwest` from optional to required
|
||||
- `owlry-core/src/config/mod.rs` — add `converter` config toggle (calculator and system already exist)
|
||||
|
||||
### Provider classification
|
||||
|
||||
- Calculator → dynamic (queried per-keystroke via `query()`)
|
||||
- Converter → dynamic (queried per-keystroke via `query()`)
|
||||
- System → static (populated at `refresh()`, returns fixed list of actions)
|
||||
|
||||
## Provider Type IDs
|
||||
|
||||
Built-in providers use `ProviderType::Plugin(String)` with fixed IDs to maintain backward compatibility with the UI highlighting and filter system:
|
||||
|
||||
- Calculator: `ProviderType::Plugin("calc".into())`
|
||||
- Converter: `ProviderType::Plugin("conv".into())`
|
||||
- System: `ProviderType::Plugin("sys".into())`
|
||||
|
||||
This ensures the UI's highlighting logic (`matches!(id.as_str(), "calc" | "conv")`) and CSS badge classes (`.owlry-badge-calc`, `.owlry-badge-sys`) continue to work without changes.
|
||||
|
||||
## Config
|
||||
|
||||
Existing toggles in `[providers]`:
|
||||
|
||||
```toml
|
||||
[providers]
|
||||
calculator = true # already exists
|
||||
system = true # already exists
|
||||
converter = true # new — add with default true
|
||||
```
|
||||
|
||||
When a toggle is false, the provider is not registered in `ProviderManager` at startup.
|
||||
|
||||
## Currency Conversion
|
||||
|
||||
The converter's currency feature uses `reqwest` (blocking) to fetch ECB exchange rates with a 24-hour file cache at `~/.cache/owlry/ecb_rates.json`. If the HTTP fetch fails (no network, timeout), currency conversion silently returns no results — unit conversion still works. This matches current plugin behavior.
|
||||
|
||||
## AUR Changes
|
||||
|
||||
### Main repo (owlry)
|
||||
|
||||
- `aur/owlry-core/PKGBUILD` — bump version
|
||||
- Remove `aur/owlry-meta-*` directories (4 dirs, already deleted from AUR)
|
||||
|
||||
### Plugins repo (owlry-plugins)
|
||||
|
||||
- Remove crates: `owlry-plugin-calculator`, `owlry-plugin-converter`, `owlry-plugin-system`
|
||||
- Remove AUR dirs: `aur/owlry-plugin-calculator`, `aur/owlry-plugin-converter`, `aur/owlry-plugin-system` from tracked files
|
||||
- Push transitional PKGBUILDs to the 3 AUR repos:
|
||||
|
||||
```bash
|
||||
pkgname=owlry-plugin-calculator # (and converter, system)
|
||||
pkgver=<last_version>
|
||||
pkgrel=99
|
||||
pkgdesc="Transitional package — calculator is now built into owlry-core"
|
||||
arch=('any')
|
||||
depends=('owlry-core>=<new_version>')
|
||||
replaces=('owlry-plugin-calculator')
|
||||
# No source, no build, no package body
|
||||
```
|
||||
|
||||
### Conflict prevention
|
||||
|
||||
When owlry-core gains built-in calculator/converter/system, users who have the old `.so` plugins installed will have both the built-in provider AND the `.so` plugin active — duplicate results. The daemon should detect this: if a built-in provider ID matches a loaded native plugin ID, skip the native plugin. Add this check in `ProviderManager` when registering native plugins.
|
||||
|
||||
## README Updates
|
||||
|
||||
### Main repo README
|
||||
|
||||
- Package table: remove separate plugin entries for calculator, converter, system — note them as built-in to owlry-core
|
||||
- Remove meta package section entirely
|
||||
- Update install examples (no need to install calculator/converter/system separately)
|
||||
|
||||
### Plugins repo README
|
||||
|
||||
- Remove calculator, converter, system from plugin listing
|
||||
- Add note that these 3 are built into owlry-core
|
||||
|
||||
## Testing
|
||||
|
||||
- Port existing plugin tests directly — they test provider logic, not FFI wrappers
|
||||
- `cargo test -p owlry-core --lib` covers all 3 new providers
|
||||
- Add conflict detection test (built-in provider ID vs native plugin ID)
|
||||
- Manual verification: `= 5+3` (calc), `20F` (conv), `20 euro to dollar` (currency), system actions
|
||||
568
justfile
568
justfile
@@ -1,65 +1,57 @@
|
||||
# Owlry build and release automation
|
||||
|
||||
# Default recipe
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# Build debug (all workspace members)
|
||||
# === Build ===
|
||||
|
||||
build:
|
||||
cargo build --workspace
|
||||
|
||||
# Build UI binary only
|
||||
build-ui:
|
||||
cargo build -p owlry
|
||||
|
||||
# Build core daemon only
|
||||
build-daemon:
|
||||
cargo build -p owlry-core
|
||||
|
||||
# Build core daemon release
|
||||
release-daemon:
|
||||
cargo build -p owlry-core --release
|
||||
|
||||
# Run core daemon
|
||||
run-daemon *ARGS:
|
||||
cargo run -p owlry-core -- {{ARGS}}
|
||||
|
||||
# Build release
|
||||
release:
|
||||
cargo build --workspace --release
|
||||
|
||||
# Run in debug mode
|
||||
release-daemon:
|
||||
cargo build -p owlry-core --release
|
||||
|
||||
# === Run ===
|
||||
|
||||
run *ARGS:
|
||||
cargo run -p owlry -- {{ARGS}}
|
||||
|
||||
# Run tests
|
||||
run-daemon *ARGS:
|
||||
cargo run -p owlry-core -- {{ARGS}}
|
||||
|
||||
# === Quality ===
|
||||
|
||||
test:
|
||||
cargo test --workspace
|
||||
|
||||
# Check code
|
||||
check:
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace
|
||||
|
||||
# Format code
|
||||
fmt:
|
||||
cargo fmt --all
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
cargo clean
|
||||
|
||||
# Install locally (core + runtimes)
|
||||
# === Install ===
|
||||
|
||||
install-local:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "Building release..."
|
||||
# Build UI without embedded Lua (smaller binary)
|
||||
cargo build -p owlry --release --no-default-features
|
||||
# Build core daemon
|
||||
cargo build -p owlry-core --release
|
||||
# Build runtimes
|
||||
cargo build -p owlry-lua -p owlry-rune --release
|
||||
|
||||
echo "Creating directories..."
|
||||
@@ -68,58 +60,24 @@ install-local:
|
||||
|
||||
echo "Installing binaries..."
|
||||
sudo install -Dm755 target/release/owlry /usr/bin/owlry
|
||||
sudo install -Dm755 target/release/owlry-core /usr/bin/owlry-core
|
||||
sudo install -Dm755 target/release/owlryd /usr/bin/owlryd
|
||||
|
||||
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
|
||||
[ -f target/release/libowlry_lua.so ] && sudo install -Dm755 target/release/libowlry_lua.so /usr/lib/owlry/runtimes/liblua.so
|
||||
[ -f target/release/libowlry_rune.so ] && sudo install -Dm755 target/release/libowlry_rune.so /usr/lib/owlry/runtimes/librune.so
|
||||
|
||||
echo "Installing systemd service files..."
|
||||
if [ -f "systemd/owlry-core.service" ]; then
|
||||
sudo install -Dm644 systemd/owlry-core.service /usr/lib/systemd/user/owlry-core.service
|
||||
echo " → owlry-core.service"
|
||||
fi
|
||||
if [ -f "systemd/owlry-core.socket" ]; then
|
||||
sudo install -Dm644 systemd/owlry-core.socket /usr/lib/systemd/user/owlry-core.socket
|
||||
echo " → owlry-core.socket"
|
||||
fi
|
||||
[ -f systemd/owlryd.service ] && sudo install -Dm644 systemd/owlryd.service /usr/lib/systemd/user/owlryd.service
|
||||
[ -f systemd/owlryd.socket ] && sudo install -Dm644 systemd/owlryd.socket /usr/lib/systemd/user/owlryd.socket
|
||||
|
||||
echo ""
|
||||
echo "Installation complete!"
|
||||
echo " - /usr/bin/owlry (UI)"
|
||||
echo " - /usr/bin/owlry-core (daemon)"
|
||||
echo " - $(ls /usr/lib/owlry/runtimes/*.so 2>/dev/null | wc -l) runtimes"
|
||||
echo " - systemd: owlry-core.service, owlry-core.socket"
|
||||
echo ""
|
||||
echo "To start the daemon:"
|
||||
echo " systemctl --user enable --now owlry-core.service"
|
||||
echo " OR add 'exec-once = owlry-core' to your compositor config"
|
||||
echo ""
|
||||
echo "Note: Install plugins separately from the owlry-plugins repo."
|
||||
echo "Done. Start daemon: systemctl --user enable --now owlryd.service"
|
||||
|
||||
# === Release Management ===
|
||||
# === Version Management ===
|
||||
|
||||
# AUR package directories (relative to project root)
|
||||
aur_core_dir := "aur/owlry"
|
||||
|
||||
# Get current version from core crate
|
||||
version := `grep '^version' crates/owlry/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/'`
|
||||
|
||||
# Show current version
|
||||
show-version:
|
||||
@echo "Current version: {{version}}"
|
||||
|
||||
# Show all crate versions
|
||||
show-versions:
|
||||
#!/usr/bin/env bash
|
||||
echo "=== Crate Versions ==="
|
||||
for toml in Cargo.toml crates/*/Cargo.toml; do
|
||||
for toml in crates/*/Cargo.toml; do
|
||||
name=$(grep '^name' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
ver=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
printf " %-30s %s\n" "$name" "$ver"
|
||||
@@ -129,20 +87,16 @@ show-versions:
|
||||
crate-version crate:
|
||||
@grep '^version' crates/{{crate}}/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/'
|
||||
|
||||
# Bump a specific crate version (usage: just bump-crate owlry-core 0.2.0)
|
||||
# Bump a single crate version, update Cargo.lock, commit
|
||||
bump-crate crate new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
toml="crates/{{crate}}/Cargo.toml"
|
||||
if [ ! -f "$toml" ]; then
|
||||
echo "Error: $toml not found"
|
||||
exit 1
|
||||
fi
|
||||
[ -f "$toml" ] || { echo "Error: $toml not found"; exit 1; }
|
||||
|
||||
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
if [ "$old" = "{{new_version}}" ]; then
|
||||
echo "{{crate}} is already at {{new_version}}, skipping"
|
||||
exit 0
|
||||
fi
|
||||
[ "$old" = "{{new_version}}" ] && { echo "{{crate}} already at {{new_version}}"; exit 0; }
|
||||
|
||||
echo "Bumping {{crate}} from $old to {{new_version}}"
|
||||
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
|
||||
cargo check -p {{crate}}
|
||||
@@ -150,7 +104,214 @@ bump-crate crate new_version:
|
||||
git commit -m "chore({{crate}}): bump version to {{new_version}}"
|
||||
echo "{{crate}} bumped to {{new_version}}"
|
||||
|
||||
# Bump meta-packages (no crate, just AUR version)
|
||||
# Bump all crates to same version
|
||||
bump-all new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
for toml in crates/*/Cargo.toml; do
|
||||
crate=$(basename $(dirname "$toml"))
|
||||
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
[ "$old" = "{{new_version}}" ] && continue
|
||||
echo "Bumping $crate from $old to {{new_version}}"
|
||||
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
|
||||
done
|
||||
cargo check --workspace
|
||||
git add crates/*/Cargo.toml Cargo.lock
|
||||
git commit -m "chore: bump all crates to {{new_version}}"
|
||||
echo "All crates bumped to {{new_version}}"
|
||||
|
||||
# Bump core UI only
|
||||
bump new_version:
|
||||
just bump-crate owlry {{new_version}}
|
||||
|
||||
# === Tagging ===
|
||||
|
||||
# Tag a specific crate (format: {crate}-v{version})
|
||||
tag-crate crate:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ver=$(grep '^version' "crates/{{crate}}/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
tag="{{crate}}-v$ver"
|
||||
if git rev-parse "$tag" >/dev/null 2>&1; then
|
||||
echo "Tag $tag already exists"
|
||||
exit 0
|
||||
fi
|
||||
git tag -a "$tag" -m "{{crate}} v$ver"
|
||||
echo "Created tag $tag"
|
||||
|
||||
# Push all local tags
|
||||
push-tags:
|
||||
git push --tags
|
||||
|
||||
# === AUR Package Management ===
|
||||
|
||||
# Stage AUR files into the main repo git index.
|
||||
# AUR subdirs have their own .git (for aur.archlinux.org), which makes
|
||||
# git treat them as embedded repos. Temporarily hide .git to stage files.
|
||||
aur-stage pkg:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
dir="aur/{{pkg}}"
|
||||
[ -d "$dir" ] || { echo "Error: $dir not found"; exit 1; }
|
||||
|
||||
# Build list of files to stage
|
||||
files=("$dir/PKGBUILD" "$dir/.SRCINFO")
|
||||
for f in "$dir"/*.install; do
|
||||
[ -f "$f" ] && files+=("$f")
|
||||
done
|
||||
|
||||
if [ -d "$dir/.git" ]; then
|
||||
mv "$dir/.git" "$dir/.git.bak"
|
||||
git add "${files[@]}"
|
||||
mv "$dir/.git.bak" "$dir/.git"
|
||||
else
|
||||
git add "${files[@]}"
|
||||
fi
|
||||
|
||||
# Update a specific AUR package PKGBUILD with correct version + checksum
|
||||
aur-update-pkg pkg:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
aur_dir="aur/{{pkg}}"
|
||||
[ -d "$aur_dir" ] || { echo "Error: $aur_dir not found"; exit 1; }
|
||||
|
||||
# Determine version
|
||||
case "{{pkg}}" in
|
||||
owlry-meta-*)
|
||||
ver=$(grep '^pkgver=' "$aur_dir/PKGBUILD" | sed 's/pkgver=//')
|
||||
echo "Meta-package {{pkg}} at $ver (bump pkgrel manually if needed)"
|
||||
(cd "$aur_dir" && makepkg --printsrcinfo > .SRCINFO)
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
crate_dir="crates/{{pkg}}"
|
||||
[ -d "$crate_dir" ] || { echo "Error: $crate_dir not found"; exit 1; }
|
||||
ver=$(grep '^version' "$crate_dir/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
;;
|
||||
esac
|
||||
|
||||
tag="{{pkg}}-v$ver"
|
||||
url="https://somegit.dev/Owlibou/owlry/archive/$tag.tar.gz"
|
||||
|
||||
echo "Updating {{pkg}} to $ver (tag: $tag)"
|
||||
sed -i "s/^pkgver=.*/pkgver=$ver/" "$aur_dir/PKGBUILD"
|
||||
sed -i 's/^pkgrel=.*/pkgrel=1/' "$aur_dir/PKGBUILD"
|
||||
|
||||
# Update checksum from the tagged tarball
|
||||
if grep -q "^source=" "$aur_dir/PKGBUILD"; then
|
||||
echo "Downloading tarball and computing checksum..."
|
||||
hash=$(curl -sL "$url" | b2sum | cut -d' ' -f1)
|
||||
if [ -z "$hash" ] || [ ${#hash} -lt 64 ]; then
|
||||
echo "Error: failed to download or hash $url"
|
||||
exit 1
|
||||
fi
|
||||
sed -i "s|^b2sums=.*|b2sums=('$hash')|" "$aur_dir/PKGBUILD"
|
||||
fi
|
||||
|
||||
(cd "$aur_dir" && makepkg --printsrcinfo > .SRCINFO)
|
||||
echo "{{pkg}} PKGBUILD updated to $ver"
|
||||
|
||||
# Shortcut: update core UI AUR package
|
||||
aur-update:
|
||||
just aur-update-pkg owlry
|
||||
|
||||
# Publish a specific AUR package to aur.archlinux.org
|
||||
aur-publish-pkg pkg:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
aur_dir="aur/{{pkg}}"
|
||||
[ -d "$aur_dir/.git" ] || { echo "Error: $aur_dir has no AUR git repo"; exit 1; }
|
||||
|
||||
cd "$aur_dir"
|
||||
ver=$(grep '^pkgver=' PKGBUILD | sed 's/pkgver=//')
|
||||
git add -A
|
||||
git commit -m "Update to v$ver" || { echo "Nothing to commit"; exit 0; }
|
||||
git push origin master
|
||||
echo "{{pkg}} v$ver published to AUR!"
|
||||
|
||||
# Shortcut: publish core UI to AUR
|
||||
aur-publish:
|
||||
just aur-publish-pkg owlry
|
||||
|
||||
# Update and publish ALL AUR packages
|
||||
aur-update-all:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
for dir in aur/*/; do
|
||||
pkg=$(basename "$dir")
|
||||
[ -f "$dir/PKGBUILD" ] || continue
|
||||
echo "=== $pkg ==="
|
||||
just aur-update-pkg "$pkg"
|
||||
echo ""
|
||||
done
|
||||
echo "All updated. Run 'just aur-publish-all' to publish."
|
||||
|
||||
aur-publish-all:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
for dir in aur/*/; do
|
||||
pkg=$(basename "$dir")
|
||||
[ -d "$dir/.git" ] || continue
|
||||
echo "=== $pkg ==="
|
||||
just aur-publish-pkg "$pkg"
|
||||
echo ""
|
||||
done
|
||||
echo "All published!"
|
||||
|
||||
# Show AUR package status
|
||||
aur-status:
|
||||
#!/usr/bin/env bash
|
||||
echo "=== AUR Package Status ==="
|
||||
for dir in aur/*/; do
|
||||
pkg=$(basename "$dir")
|
||||
[ -f "$dir/PKGBUILD" ] || continue
|
||||
ver=$(grep '^pkgver=' "$dir/PKGBUILD" | sed 's/pkgver=//')
|
||||
if [ -d "$dir/.git" ]; then
|
||||
printf " ✓ %-30s %s\n" "$pkg" "$ver"
|
||||
else
|
||||
printf " ✗ %-30s %s (no AUR repo)\n" "$pkg" "$ver"
|
||||
fi
|
||||
done
|
||||
|
||||
# Commit AUR file changes to the main repo (handles embedded .git dirs)
|
||||
aur-commit msg="chore(aur): update PKGBUILDs":
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
for dir in aur/*/; do
|
||||
pkg=$(basename "$dir")
|
||||
[ -f "$dir/PKGBUILD" ] || continue
|
||||
just aur-stage "$pkg"
|
||||
done
|
||||
git diff --cached --quiet && { echo "No AUR changes to commit"; exit 0; }
|
||||
git commit -m "{{msg}}"
|
||||
|
||||
# === Release Workflows ===
|
||||
|
||||
# Release a single crate: bump → push → tag → update AUR → publish AUR
|
||||
release-crate crate new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
just bump-crate {{crate}} {{new_version}}
|
||||
git push
|
||||
|
||||
just tag-crate {{crate}}
|
||||
just push-tags
|
||||
|
||||
echo "Waiting for tag to propagate..."
|
||||
sleep 3
|
||||
|
||||
just aur-update-pkg {{crate}}
|
||||
just aur-commit "chore(aur): update {{crate}} to {{new_version}}"
|
||||
git push
|
||||
|
||||
just aur-publish-pkg {{crate}}
|
||||
echo ""
|
||||
echo "{{crate}} v{{new_version}} released and published to AUR!"
|
||||
|
||||
# === Meta Package Management ===
|
||||
|
||||
# Bump meta-package versions
|
||||
bump-meta new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -165,271 +326,14 @@ bump-meta new_version:
|
||||
done
|
||||
echo "Meta-packages bumped to {{new_version}}"
|
||||
|
||||
# Bump all crates (core UI + daemon + plugin-api + runtimes) to same version
|
||||
bump-all new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
for toml in crates/*/Cargo.toml; do
|
||||
crate=$(basename $(dirname "$toml"))
|
||||
old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
if [ "$old" != "{{new_version}}" ]; then
|
||||
echo "Bumping $crate from $old to {{new_version}}"
|
||||
sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml"
|
||||
fi
|
||||
done
|
||||
cargo check --workspace
|
||||
git add crates/*/Cargo.toml Cargo.lock
|
||||
git commit -m "chore: bump all crates to {{new_version}}"
|
||||
echo "All crates bumped to {{new_version}}"
|
||||
|
||||
# Bump core version (usage: just bump 0.2.0)
|
||||
bump new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [ "{{version}}" = "{{new_version}}" ]; then
|
||||
echo "Version is already {{new_version}}, skipping bump"
|
||||
exit 0
|
||||
fi
|
||||
echo "Bumping core version from {{version}} to {{new_version}}"
|
||||
sed -i 's/^version = ".*"/version = "{{new_version}}"/' crates/owlry/Cargo.toml
|
||||
cargo check -p owlry
|
||||
git add crates/owlry/Cargo.toml Cargo.lock
|
||||
git commit -m "chore: bump version to {{new_version}}"
|
||||
echo "Version bumped to {{new_version}}"
|
||||
|
||||
# Create and push a release tag
|
||||
tag:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if git rev-parse "v{{version}}" >/dev/null 2>&1; then
|
||||
echo "Tag v{{version}} already exists, skipping"
|
||||
exit 0
|
||||
fi
|
||||
echo "Creating tag v{{version}}"
|
||||
git tag -a "v{{version}}" -m "Release v{{version}}"
|
||||
git push origin "v{{version}}"
|
||||
echo "Tag v{{version}} pushed"
|
||||
|
||||
# Update AUR package (core UI)
|
||||
aur-update:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "{{aur_core_dir}}"
|
||||
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
|
||||
echo "Updating PKGBUILD to version {{version}}"
|
||||
sed -i 's/^pkgver=.*/pkgver={{version}}/' PKGBUILD
|
||||
sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD
|
||||
|
||||
# Update checksums (b2sums)
|
||||
echo "Updating checksums..."
|
||||
b2sum=$(curl -sL "$url/archive/v{{version}}.tar.gz" | b2sum | cut -d' ' -f1)
|
||||
sed -i "s/^b2sums=.*/b2sums=('$b2sum')/" PKGBUILD
|
||||
|
||||
# Generate .SRCINFO
|
||||
echo "Generating .SRCINFO..."
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
|
||||
# Show diff
|
||||
git diff
|
||||
|
||||
echo ""
|
||||
echo "AUR package updated. Review changes above."
|
||||
echo "Run 'just aur-publish' to commit and push."
|
||||
|
||||
# Publish AUR package (core UI)
|
||||
aur-publish:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "{{aur_core_dir}}"
|
||||
|
||||
git add PKGBUILD .SRCINFO
|
||||
git commit -m "Update to v{{version}}"
|
||||
git push
|
||||
|
||||
echo "AUR package v{{version}} published!"
|
||||
|
||||
# Test AUR package build locally (core UI)
|
||||
aur-test:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "{{aur_core_dir}}"
|
||||
|
||||
echo "Testing PKGBUILD..."
|
||||
makepkg -sf
|
||||
|
||||
echo ""
|
||||
echo "Package built successfully!"
|
||||
ls -lh *.pkg.tar.zst
|
||||
|
||||
# === AUR Package Management (individual packages) ===
|
||||
|
||||
# Update a specific AUR package (usage: just aur-update-pkg owlry-core)
|
||||
aur-update-pkg pkg:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
aur_dir="aur/{{pkg}}"
|
||||
|
||||
if [ ! -d "$aur_dir" ]; then
|
||||
echo "Error: $aur_dir not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
|
||||
# Determine crate version
|
||||
case "{{pkg}}" in
|
||||
owlry-meta-essentials|owlry-meta-tools|owlry-meta-widgets|owlry-meta-full)
|
||||
# Meta-packages use static versioning (1.0.0), only bump pkgrel for dep changes
|
||||
crate_ver=$(grep '^pkgver=' "$aur_dir/PKGBUILD" | sed 's/pkgver=//')
|
||||
;;
|
||||
*)
|
||||
# Get version from crate
|
||||
crate_dir="crates/{{pkg}}"
|
||||
if [ ! -d "$crate_dir" ]; then
|
||||
echo "Error: $crate_dir not found"
|
||||
exit 1
|
||||
fi
|
||||
crate_ver=$(grep '^version' "$crate_dir/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
;;
|
||||
esac
|
||||
|
||||
cd "$aur_dir"
|
||||
|
||||
echo "Updating {{pkg}} PKGBUILD:"
|
||||
echo " pkgver=$crate_ver"
|
||||
|
||||
sed -i "s/^pkgver=.*/pkgver=$crate_ver/" PKGBUILD
|
||||
sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD
|
||||
|
||||
# Update checksums
|
||||
if grep -q "^source=" PKGBUILD; then
|
||||
echo "Updating checksums..."
|
||||
b2sum=$(curl -sL "$url/archive/v$crate_ver.tar.gz" | b2sum | cut -d' ' -f1)
|
||||
sed -i "s/^b2sums=.*/b2sums=('$b2sum')/" PKGBUILD
|
||||
fi
|
||||
|
||||
# Generate .SRCINFO
|
||||
echo "Generating .SRCINFO..."
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
|
||||
git diff --stat
|
||||
echo ""
|
||||
echo "{{pkg}} updated. Run 'just aur-publish-pkg {{pkg}}' to publish."
|
||||
|
||||
# Publish a specific AUR package
|
||||
aur-publish-pkg pkg:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
aur_dir="aur/{{pkg}}"
|
||||
|
||||
if [ ! -d "$aur_dir" ]; then
|
||||
echo "Error: $aur_dir not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$aur_dir"
|
||||
ver=$(grep '^pkgver=' PKGBUILD | sed 's/pkgver=//')
|
||||
|
||||
git add PKGBUILD .SRCINFO
|
||||
git commit -m "Update to v$ver"
|
||||
git push origin master
|
||||
|
||||
echo "{{pkg}} v$ver published!"
|
||||
# === Testing ===
|
||||
|
||||
# Test a specific AUR package build locally
|
||||
aur-test-pkg pkg:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "aur/{{pkg}}"
|
||||
|
||||
echo "Testing {{pkg}} PKGBUILD..."
|
||||
makepkg -sf
|
||||
|
||||
echo ""
|
||||
echo "Package built successfully!"
|
||||
ls -lh *.pkg.tar.zst
|
||||
|
||||
# List all AUR packages with their versions
|
||||
aur-status:
|
||||
#!/usr/bin/env bash
|
||||
echo "=== AUR Package Status ==="
|
||||
for dir in aur/*/; do
|
||||
pkg=$(basename "$dir")
|
||||
if [ -f "$dir/PKGBUILD" ]; then
|
||||
ver=$(grep '^pkgver=' "$dir/PKGBUILD" | sed 's/pkgver=//')
|
||||
if [ -d "$dir/.git" ]; then
|
||||
status="✓"
|
||||
else
|
||||
status="✗ (not initialized)"
|
||||
fi
|
||||
printf " %s %-30s %s\n" "$status" "$pkg" "$ver"
|
||||
fi
|
||||
done
|
||||
|
||||
# Update ALL AUR packages (core + daemon + runtimes + meta)
|
||||
aur-update-all:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "=== Updating core UI ==="
|
||||
just aur-update
|
||||
echo ""
|
||||
echo "=== Updating core daemon ==="
|
||||
just aur-update-pkg owlry-core
|
||||
echo ""
|
||||
echo "=== Updating runtimes ==="
|
||||
just aur-update-pkg owlry-lua
|
||||
just aur-update-pkg owlry-rune
|
||||
echo ""
|
||||
echo "=== Updating meta-packages ==="
|
||||
for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do
|
||||
echo "--- $pkg ---"
|
||||
(cd "aur/$pkg" && makepkg --printsrcinfo > .SRCINFO)
|
||||
done
|
||||
echo ""
|
||||
echo "All AUR packages updated. Run 'just aur-publish-all' to publish."
|
||||
|
||||
# Publish ALL AUR packages
|
||||
aur-publish-all:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "=== Publishing core UI ==="
|
||||
just aur-publish
|
||||
echo ""
|
||||
echo "=== Publishing core daemon ==="
|
||||
just aur-publish-pkg owlry-core
|
||||
echo ""
|
||||
echo "=== Publishing runtimes ==="
|
||||
just aur-publish-pkg owlry-lua
|
||||
just aur-publish-pkg owlry-rune
|
||||
echo ""
|
||||
echo "=== Publishing meta-packages ==="
|
||||
for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do
|
||||
echo "--- $pkg ---"
|
||||
just aur-publish-pkg "$pkg"
|
||||
done
|
||||
echo ""
|
||||
echo "All AUR packages published!"
|
||||
|
||||
# Full release workflow for core only (bump + tag + aur)
|
||||
release-core new_version: (bump new_version)
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Push version bump
|
||||
git push
|
||||
|
||||
# Create and push tag
|
||||
just tag
|
||||
|
||||
# Wait for tag to be available
|
||||
echo "Waiting for tag to propagate..."
|
||||
sleep 2
|
||||
|
||||
# Update AUR
|
||||
just aur-update
|
||||
|
||||
echo ""
|
||||
echo "Core release v{{new_version}} prepared!"
|
||||
echo "Review AUR changes, then run 'just aur-publish'"
|
||||
|
||||
@@ -5,7 +5,7 @@ After=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/owlry-core
|
||||
ExecStart=/usr/bin/owlryd
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
Environment=RUST_LOG=warn
|
||||
Reference in New Issue
Block a user