Compare commits

...

28 Commits

Author SHA1 Message Date
95a698225c chore(owlry-rune): bump version to 1.1.2 2026-04-05 18:05:54 +02:00
709e1b04cb chore(aur): update owlry-lua to 1.1.2 2026-04-05 18:05:31 +02:00
827bf383ea chore(owlry-lua): bump version to 1.1.2 2026-04-05 18:05:25 +02:00
b706347ec9 chore(aur): update owlry-core to 1.3.2 2026-04-05 17:59:24 +02:00
32b4b144f4 chore(owlry-core): bump version to 1.3.2 2026-04-05 17:59:19 +02:00
5615002062 fix: switch reqwest TLS backend from rustls to native-tls
reqwest 0.13 defaults to rustls -> aws-lc-rs which requires cmake/nasm
in minimal build environments (AUR chroot). Switch all direct reqwest
users to native-tls (system OpenSSL) to fix clean chroot build failures
reported by users.

Affected crates: owlry-core, owlry-lua, owlry-rune
PKGBUILD: add openssl to depends for all three runtime packages
Also add scripts/aur-local-test for clean chroot testing workflow
2026-04-05 17:58:36 +02:00
0a3af9fa56 refactor(filter): consolidate parse_query prefix arrays
Merge four separate prefix arrays (core full, plugin full, core
partial, plugin partial) into two arrays with a single loop each
that checks both full and partial match. Halves the data and
eliminates the duplicate iteration.
2026-03-29 20:45:52 +02:00
c93b11e899 perf(application): single-pass double-space collapse
Replace while-contains-replace loop with a single-pass char
iterator. Eliminates O(n²) behavior and repeated allocations
on pathological desktop file Exec values.
2026-03-29 20:44:27 +02:00
bd69f8eafe perf(ui): use ListBox::remove_all() instead of per-child loop
Replaces five while-loop child removal patterns with the batched
remove_all() method available since GTK 4.12. Avoids per-removal
layout invalidation.
2026-03-29 20:43:41 +02:00
edfb079bb1 perf(frecency): remove blocking auto-save on every launch
record_launch no longer calls save() synchronously. The dirty flag
is set and the Drop impl flushes on shutdown. Removes a JSON
serialize + fs::write from the hot launch path.
2026-03-29 20:41:56 +02:00
3de382cd73 perf(search): score by reference, clone only top-N results
Refactor search_with_frecency to score static provider items by
reference (&LaunchItem, i64) instead of cloning every match.
Use select_nth_unstable_by for O(n) partial sort, then clone
only the max_results survivors. Reduces clones from O(total_matches)
to O(max_results) — typically from hundreds to ~15.
2026-03-29 20:33:29 +02:00
82f35e5a54 fix(native-provider): remove unsound unsafe in items()
Replace RwLock<Vec<LaunchItem>> with plain Vec. The inner RwLock
was unnecessary — refresh() takes &mut self (exclusive access
guaranteed by the outer Arc<RwLock<ProviderManager>>). The unsafe
block in items() dropped the RwLockReadGuard while returning a
slice backed by the raw pointer, creating a dangling reference.
2026-03-29 20:28:49 +02:00
a920588df9 chore(aur): update owlry-lua 1.1.1, owlry-rune 1.1.1 2026-03-28 13:43:30 +01:00
c32b6c5456 chore(owlry-rune): bump version to 1.1.1 2026-03-28 13:43:06 +01:00
2a5f184230 chore(owlry-lua): bump version to 1.1.1 2026-03-28 13:43:04 +01:00
b2f068269a chore: remove unused builtin_type_ids method and test 2026-03-28 13:37:54 +01:00
e210a604f7 chore(aur): update owlry-core to 1.3.1 2026-03-28 13:30:28 +01:00
1adec7bf47 chore(owlry-core): bump version to 1.3.1 2026-03-28 13:30:23 +01:00
7f07a93dec fix(core): add :config and :conv to filter prefix tables
:config and :conv were not in the prefix lists, so typing them
showed 'Plugin' mode but didn't route to the config/converter
providers. Also added :settings, :converter aliases.
2026-03-28 13:30:10 +01:00
7351ba868e docs: revise README for current state
- Architecture diagram reflects owlryd binary name and built-in providers
- Add config editor, converter trigger (>) to prefix tables
- Add apex-neon to theme list (10 themes)
- Add --owlry-shadow CSS variable
- Fix build instructions (no deleted plugins)
- Add built-in provider toggles to example config
- Cross-reference :config throughout (Quick Start, Disabling Plugins, Theming)
2026-03-28 13:28:32 +01:00
44e1430ea5 chore(aur): update owlry-core to 1.3.0 2026-03-28 13:17:29 +01:00
80312a28f7 chore(owlry-core): bump version to 1.3.0 2026-03-28 13:17:11 +01:00
37abe98c9b docs: add config editor usage to README 2026-03-28 13:16:36 +01:00
d95b81bbcb feat(core): wire config editor into ProviderManager
Register ConfigProvider as built-in dynamic provider. Extend
execute_plugin_action to dispatch CONFIG:* commands via the
DynamicProvider::execute_action trait method.
2026-03-28 13:15:28 +01:00
562b38deba feat(core): add built-in config editor provider 2026-03-28 13:10:54 +01:00
2888677e38 docs: add config editor implementation plan 2026-03-28 13:05:57 +01:00
940ad58ee2 docs: add config editor design spec 2026-03-28 12:54:11 +01:00
18775d71fc chore(aur): update owlry 1.0.6, owlry-core 1.2.1 2026-03-28 12:40:33 +01:00
35 changed files with 7017 additions and 571 deletions

432
Cargo.lock generated
View File

@@ -309,28 +309,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "base64"
version = "0.22.1"
@@ -430,17 +408,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cfg-expr"
version = "0.20.7"
@@ -517,15 +487,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmake"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
dependencies = [
"cc",
]
[[package]]
name = "codespan-reporting"
version = "0.11.1"
@@ -542,16 +503,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "compression-codecs"
version = "0.4.37"
@@ -587,16 +538,6 @@ dependencies = [
"typewit",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -722,27 +663,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -896,6 +822,21 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -920,12 +861,6 @@ dependencies = [
"xdg",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "fsevent-sys"
version = "4.1.0"
@@ -1504,25 +1439,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1605,7 +1521,6 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1634,6 +1549,22 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -1652,11 +1583,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -1884,60 +1813,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.91"
@@ -2157,12 +2032,6 @@ dependencies = [
"nom",
]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2259,6 +2128,23 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "nix"
version = "0.31.2"
@@ -2503,12 +2389,50 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl"
version = "0.10.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.112"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2557,7 +2481,7 @@ dependencies = [
[[package]]
name = "owlry-core"
version = "1.2.1"
version = "1.3.2"
dependencies = [
"chrono",
"ctrlc",
@@ -2584,7 +2508,7 @@ dependencies = [
[[package]]
name = "owlry-lua"
version = "1.1.0"
version = "1.1.2"
dependencies = [
"abi_stable",
"chrono",
@@ -2610,7 +2534,7 @@ dependencies = [
[[package]]
name = "owlry-rune"
version = "1.1.0"
version = "1.1.2"
dependencies = [
"chrono",
"dirs",
@@ -2863,7 +2787,6 @@ version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"lru-slab",
@@ -3056,31 +2979,26 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
dependencies = [
"base64",
"bytes",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-native-tls",
"tower",
"tower-http",
"tower-service",
@@ -3246,7 +3164,6 @@ version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"once_cell",
"ring",
"rustls-pki-types",
@@ -3255,18 +3172,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
@@ -3277,40 +3182,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@@ -3365,7 +3242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.10.1",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -3606,27 +3483,6 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00c99c9cda412afe293a6b962af651b4594161ba88c1affe7ef66459ea040a06"
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "7.0.7"
@@ -3794,6 +3650,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -4156,6 +4022,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -4324,15 +4196,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
@@ -4485,17 +4348,6 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -4532,15 +4384,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
@@ -4586,21 +4429,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
@@ -4667,12 +4495,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -4691,12 +4513,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
@@ -4715,12 +4531,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
@@ -4751,12 +4561,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
@@ -4775,12 +4579,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
@@ -4799,12 +4597,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
@@ -4823,12 +4615,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"

View File

@@ -11,17 +11,18 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
## Features
- **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
- **Built-in calculator, converter, and system actions** — Works out of the box
- **11 optional plugins** — Clipboard, emoji, weather, media, and more
- **Built-in providers** — Calculator, unit/currency converter, and system actions out of the box
- **Built-in settings editor** — Configure everything from within the launcher (`:config`)
- **11 optional plugins** — Clipboard, emoji, weather, media, bookmarks, and more
- **Widget providers** — Weather, media controls, and pomodoro timer at the top of results
- **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags
- **Config profiles** — Named mode presets for different workflows
- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:tag:development`, etc.
- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:config`, `:tag:X`, etc.
- **Frecency ranking** — Frequently/recently used items rank higher
- **Toggle behavior** — Bind one key to open/close the launcher
- **GTK4 theming** — System theme by default, with 9 built-in themes
- **GTK4 theming** — System theme by default, with 10 built-in themes
- **Wayland native** — Uses Layer Shell for proper overlay behavior
- **dmenu compatible** — Pipe-based selection mode, no daemon required
- **Extensible** — Create custom plugins in Lua or Rune
## Installation
@@ -29,13 +30,13 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
### Arch Linux (AUR)
```bash
# Core (includes calculator, converter, system actions)
# Core (includes calculator, converter, system actions, settings editor)
yay -S owlry
# Add individual plugins as needed
yay -S owlry-plugin-bookmarks owlry-plugin-weather owlry-plugin-clipboard
# For custom Lua/Rune plugins
# For custom Lua/Rune user plugins
yay -S owlry-lua # Lua 5.4 runtime
yay -S owlry-rune # Rune runtime
```
@@ -47,7 +48,7 @@ yay -S owlry-rune # Rune runtime
| Package | Description |
|---------|-------------|
| `owlry` | GTK4 UI client |
| `owlry-core` | Headless daemon with built-in calculator, converter, and system providers |
| `owlry-core` | Daemon (`owlryd`) with built-in calculator, converter, system, and settings providers |
| `owlry-lua` | Lua 5.4 script runtime for user plugins |
| `owlry-rune` | Rune script runtime for user plugins |
@@ -67,7 +68,7 @@ yay -S owlry-rune # Rune runtime
| `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.
> **Note:** Calculator, converter, and system actions are built into `owlry-core` and do not require separate packages.
### Build from Source
@@ -102,7 +103,7 @@ cargo build --release --workspace
```bash
git clone https://somegit.dev/Owlibou/owlry-plugins.git
cd owlry-plugins
cargo build --release -p owlry-plugin-calculator # or any plugin
cargo build --release -p owlry-plugin-bookmarks # or any plugin
```
**Install locally:**
@@ -110,11 +111,11 @@ cargo build --release -p owlry-plugin-calculator # or any plugin
just install-local
```
This installs the UI, daemon, runtimes, and systemd service files.
This installs the UI (`owlry`), daemon (`owlryd`), runtimes, and systemd service files.
## Getting Started
Owlry uses a client/daemon architecture. The daemon (`owlry-core`) loads providers and plugins into memory. The UI client (`owlry`) connects to the daemon over a Unix socket for instant results.
Owlry uses a client/daemon architecture. The daemon (`owlryd`) loads providers and plugins into memory. The UI client (`owlry`) connects to the daemon over a Unix socket for instant results.
### Starting the Daemon
@@ -144,7 +145,7 @@ systemctl --user enable --now owlryd.service
systemctl --user enable owlryd.socket
```
The daemon starts automatically when the UI client first connects. No manual startup needed.
The daemon starts automatically when the UI client first connects.
### Launching the UI
@@ -158,7 +159,7 @@ bind = SUPER, Space, exec, owlry
bindsym $mod+space exec owlry
```
Running `owlry` a second time while it is already open sends a toggle command — the window closes. This means a single keybind acts as open/close.
Running `owlry` a second time while it is already open sends a toggle command — the window closes. A single keybind acts as open/close.
If the daemon is not running when the UI launches, it will attempt to start it via systemd automatically.
@@ -168,7 +169,7 @@ If the daemon is not running when the UI launches, it will attempt to start it v
owlry # Launch with all providers
owlry -m app # Applications only
owlry -m cmd # PATH commands only
owlry -m calc # Calculator plugin only (if installed)
owlry -m calc # Calculator only
owlry --profile dev # Use a named profile from config
owlry --help # Show all options with examples
```
@@ -203,14 +204,16 @@ bind = SUPER, D, exec, owlry --profile dev
bind = SUPER, M, exec, owlry --profile media
```
Profiles can also be managed from the launcher itself — see [Settings Editor](#settings-editor).
### dmenu Mode
Owlry is dmenu-compatible. Pipe input for interactive selection — the selected item is printed to stdout (not executed), so you pipe the output to execute it.
dmenu mode is self-contained: it does not use the daemon and works without `owlry-core` running.
dmenu mode is self-contained: it does not use the daemon and works without `owlryd` running.
```bash
# Screenshot menu (execute selected command)
# Screenshot menu
printf '%s\n' \
"grimblast --notify copy screen" \
"grimblast --notify copy area" \
@@ -229,9 +232,6 @@ find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code
# Package manager search
pacman -Ssq | owlry -m dmenu -p "install" | xargs sudo pacman -S
# Open selected file
ls ~/Documents | owlry -m dmenu | xargs xdg-open
```
The `-p` / `--prompt` flag sets a custom label for the search input.
@@ -247,6 +247,24 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
| `Shift+Tab` | Cycle filter tabs (reverse) |
| `Ctrl+1..9` | Toggle tab by position |
### Settings Editor
Type `:config` to browse and modify settings without editing files:
| Command | What it does |
|---------|-------------|
| `:config` | Show all setting categories |
| `:config providers` | Toggle providers on/off |
| `:config theme` | Select color theme |
| `:config engine` | Select web search engine |
| `:config frecency` | Toggle frecency, set weight |
| `:config fontsize 16` | Set font size (restart to apply) |
| `:config profiles` | List profiles |
| `:config profile create dev` | Create a new profile |
| `:config profile dev modes` | Edit which modes a profile includes |
Changes are saved to `config.toml` immediately. Some settings (theme, frecency) take effect on the next search. Others (font size, dimensions) require a restart.
### Search Prefixes
| Prefix | Provider | Example |
@@ -263,6 +281,7 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
| `:calc` | Calculator | `:calc sqrt(16)` |
| `:web` | Web search | `:web rust docs` |
| `:uuctl` | systemd | `:uuctl docker` |
| `:config` | Settings | `:config theme` |
| `:tag:X` | Filter by tag | `:tag:development` |
### Trigger Prefixes
@@ -271,6 +290,7 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
|---------|----------|---------|
| `=` | Calculator | `= 5+3` |
| `calc ` | Calculator | `calc sqrt(16)` |
| `>` | Converter | `> 20 km to mi` |
| `?` | Web search | `? rust programming` |
| `web ` | Web search | `web linux tips` |
| `/` | File search | `/ .bashrc` |
@@ -290,6 +310,7 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free
| `~/.local/share/owlry/frecency.json` | Usage history |
System locations:
| Path | Purpose |
|------|---------|
| `/usr/lib/owlry/plugins/*.so` | Installed native plugins |
@@ -304,6 +325,8 @@ mkdir -p ~/.config/owlry
cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml
```
Or configure from within the launcher: type `:config` to interactively change settings.
### Example Configuration
```toml
@@ -327,6 +350,9 @@ disabled = [] # Plugin IDs to disable, e.g., ["emoji", "pomodoro"]
[providers]
applications = true # .desktop files
commands = true # PATH executables
calculator = true # Built-in math expressions
converter = true # Built-in unit/currency conversion
system = true # Built-in shutdown/reboot/lock actions
frecency = true # Boost frequently used items
frecency_weight = 0.3 # 0.0-1.0
@@ -345,7 +371,7 @@ See `/usr/share/doc/owlry/config.example.toml` for all options with documentatio
## Plugin System
Owlry uses a modular plugin architecture. Plugins are loaded by the daemon (`owlry-core`) from:
Owlry uses a modular plugin architecture. Plugins are loaded by the daemon from:
- `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages)
- `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`)
@@ -359,6 +385,8 @@ Add plugin IDs to the disabled list in your config:
disabled = ["emoji", "pomodoro"]
```
Or toggle providers interactively: type `:config providers` in the launcher.
### Plugin Management CLI
```bash
@@ -414,12 +442,15 @@ See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for:
| `tokyo-night` | Tokyo city lights |
| `solarized-dark` | Precision colors |
| `one-dark` | Atom's One Dark |
| `apex-neon` | Neon cyberpunk |
```toml
[appearance]
theme = "catppuccin-mocha"
```
Or select interactively: type `:config theme` in the launcher.
### Custom Theme
Create `~/.config/owlry/themes/mytheme.css`:
@@ -447,18 +478,24 @@ Create `~/.config/owlry/themes/mytheme.css`:
| `--owlry-text-secondary` | Muted text |
| `--owlry-accent` | Accent color |
| `--owlry-accent-bright` | Bright accent |
| `--owlry-shadow` | Window shadow (default: none) |
## Architecture
Owlry uses a client/daemon split:
```
owlry-core (daemon) owlry (GTK4 UI client)
owlryd (daemon) owlry (GTK4 UI client)
├── Loads config + plugins ├── Connects to daemon via Unix socket
├── Applications provider ├── Renders results in GTK4 window
├── Commands provider ├── Handles keyboard input
├── Plugin loader ├── Toggle: second launch closes window
│ ├── /usr/lib/owlry/plugins/*.so └── dmenu mode (self-contained, no daemon)
├── Built-in providers ├── Renders results in GTK4 window
│ ├── Applications (.desktop) ├── Handles keyboard input
│ ├── Commands (PATH) ├── Toggle: second launch closes window
│ ├── Calculator (math) └── dmenu mode (self-contained, no daemon)
│ ├── Converter (units/currency)
│ ├── System (power/session)
│ └── Config editor (settings)
├── Plugin loader
│ ├── /usr/lib/owlry/plugins/*.so
│ ├── /usr/lib/owlry/runtimes/
│ └── ~/.config/owlry/plugins/
├── Frecency tracking

View File

@@ -1,13 +1,14 @@
pkgbase = owlry-core
pkgdesc = Core daemon for the Owlry application launcher — manages plugins, providers, and search
pkgver = 1.2.0
pkgver = 1.3.2
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
depends = openssl
source = owlry-core-1.3.2.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v1.3.2.tar.gz
b2sums = 36a1e31cadcfdbe70c0a10c13eddbcea7ae21b7dcfb0aa10a75f44a82a377d6598c4237228457c13260ca4b4b88f12d416541ad7698cf28076124b1a4d3dbbc6
pkgname = owlry-core

View File

@@ -1,15 +1,15 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-core
pkgver=1.2.0
pkgver=1.3.2
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')
depends=('gcc-libs' 'openssl')
makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v$pkgver.tar.gz")
b2sums=('5e23b41ad12e3e0577213059e2509a9b42e3081b17944e300831e4cfa216628d5190e64d9fd72edc3aa34aebb387d3821ae1d9edd157acf1abf2e5b81f778fd7')
b2sums=('36a1e31cadcfdbe70c0a10c13eddbcea7ae21b7dcfb0aa10a75f44a82a377d6598c4237228457c13260ca4b4b88f12d416541ad7698cf28076124b1a4d3dbbc6')
prepare() {
cd "owlry"

View File

@@ -1,13 +1,14 @@
pkgbase = owlry-lua
pkgdesc = Lua scripting runtime for Owlry — enables user-created Lua plugins
pkgver = 1.1.0
pkgver = 1.1.2
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
depends = openssl
source = owlry-lua-1.1.2.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v1.1.2.tar.gz
b2sums = 42e6221e6e07c629ece1493e7f5feb1b2cb2e77632d1d7779dfbe544bd89a17d77d1839d63e50d71d4f0e0322ca8a1cc39b872101039019bdf08d9bcaeda7603
pkgname = owlry-lua

View File

@@ -1,15 +1,15 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-lua
pkgver=1.1.0
pkgver=1.1.2
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')
depends=('owlry-core' 'openssl')
makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v$pkgver.tar.gz")
b2sums=('d4b200446a31301b1240fd8eede6e10764d7bbc551f2e5549bfdbdcc0fa4a717677c3c2c69778d2dfa336711ac5b74d4987e46082ea589fed961c9d2ff95af76')
b2sums=('42e6221e6e07c629ece1493e7f5feb1b2cb2e77632d1d7779dfbe544bd89a17d77d1839d63e50d71d4f0e0322ca8a1cc39b872101039019bdf08d9bcaeda7603')
_cratename=owlry-lua
@@ -30,7 +30,7 @@ check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo test -p $_cratename --frozen --release
cargo test -p $_cratename --frozen --lib
}
package() {

Submodule aur/owlry-meta-essentials added at 4a09cfb73c

1
aur/owlry-meta-full Submodule

Submodule aur/owlry-meta-full added at 8f85087731

1
aur/owlry-meta-tools Submodule

Submodule aur/owlry-meta-tools added at 28c78b7953

Submodule aur/owlry-meta-widgets added at aa4c2cd217

View File

@@ -1,13 +1,13 @@
pkgbase = owlry-rune
pkgdesc = Rune scripting runtime for Owlry — enables user-created Rune plugins
pkgver = 1.1.0
pkgver = 1.1.1
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
source = owlry-rune-1.1.1.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v1.1.1.tar.gz
b2sums = a0e1fa032db8dda8e6bc24457f3c04948129d3f14c1d3e61b8e080340b24f560d43294beb133ad4b1c6eb7942d401108ea91c367b074eaeeefa284e9b2a9dbc8
pkgname = owlry-rune

View File

@@ -1,15 +1,15 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry-rune
pkgver=1.1.0
pkgver=1.1.1
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')
depends=('owlry-core' 'openssl')
makedepends=('cargo')
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v$pkgver.tar.gz")
b2sums=('d4b200446a31301b1240fd8eede6e10764d7bbc551f2e5549bfdbdcc0fa4a717677c3c2c69778d2dfa336711ac5b74d4987e46082ea589fed961c9d2ff95af76')
b2sums=('a0e1fa032db8dda8e6bc24457f3c04948129d3f14c1d3e61b8e080340b24f560d43294beb133ad4b1c6eb7942d401108ea91c367b074eaeeefa284e9b2a9dbc8')
_cratename=owlry-rune

View File

@@ -1,6 +1,6 @@
pkgbase = owlry
pkgdesc = Lightweight Wayland application launcher with plugin support
pkgver = 1.0.5
pkgver = 1.0.6
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
arch = x86_64
@@ -28,7 +28,7 @@ pkgbase = owlry
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
source = owlry-1.0.6.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v1.0.6.tar.gz
b2sums = 8967562bda33820b282350eaad17e8194699926b721eabe978fb0b70af2a75e399866c6bfa7abb449141701bad618df56079c7e81358708b1852b1070b0b7c05
pkgname = owlry

View File

@@ -1,6 +1,6 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry
pkgver=1.0.5
pkgver=1.0.6
pkgrel=1
pkgdesc="Lightweight Wayland application launcher with plugin support"
arch=('x86_64')
@@ -29,7 +29,7 @@ optdepends=(
'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')
b2sums=('8967562bda33820b282350eaad17e8194699926b721eabe978fb0b70af2a75e399866c6bfa7abb449141701bad618df56079c7e81358708b1852b1070b0b7c05')
prepare() {
cd "owlry"

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-core"
version = "1.2.1"
version = "1.3.2"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
@@ -50,7 +50,7 @@ notify-rust = "4"
# Built-in providers
meval = "0.2"
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"] }
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "blocking"] }
# Optional: embedded Lua runtime
mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true }

View File

@@ -115,11 +115,6 @@ impl FrecencyStore {
"Recorded launch for '{}': count={}, last={}",
item_id, entry.launch_count, entry.last_launch
);
// Auto-save after recording
if let Err(e) = self.save() {
warn!("Failed to save frecency data: {}", e);
}
}
/// Calculate frecency score for an item
@@ -255,4 +250,18 @@ mod tests {
assert!(score_many > score_few);
assert!((score_many / score_few - 10.0).abs() < 0.1); // Should be ~10x
}
#[test]
fn record_launch_sets_dirty_without_saving() {
let mut store = FrecencyStore {
data: FrecencyData::default(),
path: PathBuf::from("/dev/null"),
dirty: false,
};
store.record_launch("test-item");
assert!(store.dirty, "record_launch should set dirty flag");
assert_eq!(store.data.entries["test-item"].launch_count, 1);
}
}

View File

@@ -229,43 +229,48 @@ impl ProviderFilter {
}
}
// Core provider prefixes
// Core prefixes — each entry is tried as ":name " (full) and ":name" (partial)
let core_prefixes: &[(&str, ProviderType)] = &[
(":app ", ProviderType::Application),
(":apps ", ProviderType::Application),
(":cmd ", ProviderType::Command),
(":command ", ProviderType::Command),
("app", ProviderType::Application),
("apps", ProviderType::Application),
("cmd", ProviderType::Command),
("command", ProviderType::Command),
];
// Plugin provider prefixes - mapped to Plugin(type_id)
// Plugin prefixes — each entry maps to a plugin type_id
let plugin_prefixes: &[(&str, &str)] = &[
(":bm ", "bookmarks"),
(":bookmark ", "bookmarks"),
(":bookmarks ", "bookmarks"),
(":calc ", "calc"),
(":calculator ", "calc"),
(":clip ", "clipboard"),
(":clipboard ", "clipboard"),
(":emoji ", "emoji"),
(":emojis ", "emoji"),
(":file ", "filesearch"),
(":files ", "filesearch"),
(":find ", "filesearch"),
(":script ", "scripts"),
(":scripts ", "scripts"),
(":ssh ", "ssh"),
(":sys ", "system"),
(":system ", "system"),
(":power ", "system"),
(":uuctl ", "uuctl"),
(":systemd ", "uuctl"),
(":web ", "websearch"),
(":search ", "websearch"),
("bm", "bookmarks"),
("bookmark", "bookmarks"),
("bookmarks", "bookmarks"),
("calc", "calc"),
("calculator", "calc"),
("clip", "clipboard"),
("clipboard", "clipboard"),
("emoji", "emoji"),
("emojis", "emoji"),
("file", "filesearch"),
("files", "filesearch"),
("find", "filesearch"),
("script", "scripts"),
("scripts", "scripts"),
("ssh", "ssh"),
("sys", "system"),
("system", "system"),
("power", "system"),
("uuctl", "uuctl"),
("systemd", "uuctl"),
("web", "websearch"),
("search", "websearch"),
("config", "config"),
("settings", "config"),
("conv", "conv"),
("converter", "conv"),
];
// Check core prefixes
for (prefix_str, provider) in core_prefixes {
if let Some(rest) = trimmed.strip_prefix(prefix_str) {
// Single-pass: try each core prefix as both full (":name query") and partial (":name")
for (name, provider) in core_prefixes {
let with_space = format!(":{} ", name);
if let Some(rest) = trimmed.strip_prefix(with_space.as_str()) {
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
@@ -277,60 +282,8 @@ impl ProviderFilter {
query: rest.to_string(),
};
}
}
// Check plugin prefixes
for (prefix_str, type_id) in plugin_prefixes {
if let Some(rest) = trimmed.strip_prefix(prefix_str) {
let provider = ProviderType::Plugin(type_id.to_string());
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
query, provider, rest
);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: rest.to_string(),
};
}
}
// Handle partial prefixes (still typing)
let partial_core: &[(&str, ProviderType)] = &[
(":app", ProviderType::Application),
(":apps", ProviderType::Application),
(":cmd", ProviderType::Command),
(":command", ProviderType::Command),
];
let partial_plugin: &[(&str, &str)] = &[
(":bm", "bookmarks"),
(":bookmark", "bookmarks"),
(":bookmarks", "bookmarks"),
(":calc", "calc"),
(":calculator", "calc"),
(":clip", "clipboard"),
(":clipboard", "clipboard"),
(":emoji", "emoji"),
(":emojis", "emoji"),
(":file", "filesearch"),
(":files", "filesearch"),
(":find", "filesearch"),
(":script", "scripts"),
(":scripts", "scripts"),
(":ssh", "ssh"),
(":sys", "system"),
(":system", "system"),
(":power", "system"),
(":uuctl", "uuctl"),
(":systemd", "uuctl"),
(":web", "websearch"),
(":search", "websearch"),
];
for (prefix_str, provider) in partial_core {
if trimmed == *prefix_str {
let exact = format!(":{}", name);
if trimmed == exact {
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> partial prefix {:?}",
@@ -344,8 +297,24 @@ impl ProviderFilter {
}
}
for (prefix_str, type_id) in partial_plugin {
if trimmed == *prefix_str {
// Single-pass: try each plugin prefix as both full and partial
for (name, type_id) in plugin_prefixes {
let with_space = format!(":{} ", name);
if let Some(rest) = trimmed.strip_prefix(with_space.as_str()) {
let provider = ProviderType::Plugin(type_id.to_string());
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
query, provider, rest
);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: rest.to_string(),
};
}
let exact = format!(":{}", name);
if trimmed == exact {
let provider = ProviderType::Plugin(type_id.to_string());
#[cfg(feature = "dev-logging")]
debug!(

View File

@@ -58,9 +58,19 @@ fn clean_desktop_exec_field(cmd: &str) -> String {
}
// Clean up any double spaces that may have resulted from removing field codes
let mut cleaned = result.trim().to_string();
while cleaned.contains(" ") {
cleaned = cleaned.replace(" ", " ");
let trimmed = result.trim();
let mut cleaned = String::with_capacity(trimmed.len());
let mut prev_space = false;
for c in trimmed.chars() {
if c == ' ' {
if !prev_space {
cleaned.push(' ');
}
prev_space = true;
} else {
cleaned.push(c);
prev_space = false;
}
}
cleaned
@@ -271,4 +281,11 @@ mod tests {
"env FOO=bar BAZ=qux myapp"
);
}
#[test]
fn test_clean_desktop_exec_collapses_spaces() {
assert_eq!(clean_desktop_exec_field("app --flag arg"), "app --flag arg");
let input = format!("app{}arg", " ".repeat(100));
assert_eq!(clean_desktop_exec_field(&input), "app arg");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
mod application;
mod command;
pub(crate) mod calculator;
pub(crate) mod config_editor;
pub(crate) mod converter;
pub(crate) mod system;
@@ -116,6 +117,11 @@ pub(crate) trait DynamicProvider: Send + Sync {
fn provider_type(&self) -> ProviderType;
fn query(&self, query: &str) -> Vec<LaunchItem>;
fn priority(&self) -> u32;
/// Handle a plugin action command. Returns true if handled.
fn execute_action(&self, _command: &str) -> bool {
false
}
}
/// Manages all providers and handles searching
@@ -196,25 +202,6 @@ 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),
@@ -327,6 +314,11 @@ impl ProviderManager {
info!("Registered built-in converter provider");
}
// Config editor — always enabled
let config_arc = std::sync::Arc::new(std::sync::RwLock::new(config.clone()));
builtin_dynamic.push(Box::new(config_editor::ConfigProvider::new(config_arc)));
info!("Registered built-in config editor provider");
// Built-in static providers
if config.providers.system {
core_providers.push(Box::new(system::SystemProvider::new()));
@@ -529,6 +521,14 @@ impl ProviderManager {
return true;
}
}
// Check built-in dynamic providers
for provider in &self.builtin_dynamic {
if provider.execute_action(command) {
return true;
}
}
false
}
@@ -737,22 +737,17 @@ impl ProviderManager {
// Empty query (after checking special providers) - return frecency-sorted items
if query.is_empty() {
// Collect items from core providers
let core_items = self
let mut scored_refs: Vec<(&LaunchItem, i64)> = self
.providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
// Collect items from static native providers
let native_items = self
.static_native_providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
let items: Vec<(LaunchItem, i64)> = core_items
.chain(native_items)
.flat_map(|p| p.items().iter())
.chain(
self.static_native_providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter()),
)
.filter(|item| {
// Apply tag filter if present
if let Some(tag) = tag_filter {
@@ -768,8 +763,15 @@ impl ProviderManager {
})
.collect();
// Combine widgets (already in results) with frecency items
results.extend(items);
// Partial sort: O(n) average to find top max_results, then O(k log k) to order them
if scored_refs.len() > max_results {
scored_refs.select_nth_unstable_by(max_results, |a, b| b.1.cmp(&a.1));
scored_refs.truncate(max_results);
}
scored_refs.sort_by(|a, b| b.1.cmp(&a.1));
// Clone only the survivors
results.extend(scored_refs.into_iter().map(|(item, score)| (item.clone(), score)));
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
return results;
@@ -777,7 +779,7 @@ impl ProviderManager {
// Regular search with frecency boost and tag matching
// Helper closure for scoring items
let score_item = |item: &LaunchItem| -> Option<(LaunchItem, i64)> {
let score_item = |item: &LaunchItem| -> Option<i64> {
// Apply tag filter if present
if let Some(tag) = tag_filter
&& !item.tags.iter().any(|t| t.to_lowercase().contains(tag))
@@ -824,33 +826,46 @@ impl ProviderManager {
0
};
(item.clone(), s + frecency_boost + exact_match_boost)
s + frecency_boost + exact_match_boost
})
};
// Search core providers
// Score static items by reference (no cloning)
let mut scored_refs: Vec<(&LaunchItem, i64)> = Vec::new();
for provider in &self.providers {
if !filter.is_active(provider.provider_type()) {
continue;
}
for item in provider.items() {
if let Some(scored) = score_item(item) {
results.push(scored);
if let Some(score) = score_item(item) {
scored_refs.push((item, score));
}
}
}
// Search static native providers
for provider in &self.static_native_providers {
if !filter.is_active(provider.provider_type()) {
continue;
}
for item in provider.items() {
if let Some(scored) = score_item(item) {
results.push(scored);
if let Some(score) = score_item(item) {
scored_refs.push((item, score));
}
}
}
// Partial sort: O(n) average to find top max_results, then O(k log k) to order them
if scored_refs.len() > max_results {
scored_refs.select_nth_unstable_by(max_results, |a, b| b.1.cmp(&a.1));
scored_refs.truncate(max_results);
}
scored_refs.sort_by(|a, b| b.1.cmp(&a.1));
// Clone only the survivors
results.extend(scored_refs.into_iter().map(|(item, score)| (item.clone(), score)));
// Final sort merges dynamic results (already in `results`) with static top-N
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
@@ -1217,23 +1232,4 @@ mod tests {
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"));
}
}

View File

@@ -6,7 +6,7 @@
//! Native plugins are loaded from `/usr/lib/owlry/plugins/` as `.so` files
//! and provide search providers via an ABI-stable interface.
use std::sync::{Arc, RwLock};
use std::sync::Arc;
use log::debug;
use owlry_plugin_api::{
@@ -28,7 +28,7 @@ pub struct NativeProvider {
/// Handle to the provider state in the plugin
handle: ProviderHandle,
/// Cached items (for static providers)
items: RwLock<Vec<LaunchItem>>,
items: Vec<LaunchItem>,
}
impl NativeProvider {
@@ -40,7 +40,7 @@ impl NativeProvider {
plugin,
info,
handle,
items: RwLock::new(Vec::new()),
items: Vec::new(),
}
}
@@ -74,7 +74,7 @@ impl NativeProvider {
let is_special_query = query.starts_with("?SUBMENU:") || query.starts_with("!");
if self.info.provider_type != ProviderKind::Dynamic && !is_special_query {
return self.items.read().unwrap().clone();
return self.items.clone();
}
let api_items = self.plugin.query_provider(self.handle, query);
@@ -171,22 +171,11 @@ impl Provider for NativeProvider {
items.len()
);
*self.items.write().unwrap() = items;
self.items = items;
}
fn items(&self) -> &[LaunchItem] {
// This is tricky with RwLock - we need to return a reference but can't
// hold the lock across the return. We use a raw pointer approach.
//
// SAFETY: The items Vec is only modified during refresh() which takes
// &mut self, so no concurrent modification can occur while this
// reference is live.
unsafe {
let guard = self.items.read().unwrap();
let ptr = guard.as_ptr();
let len = guard.len();
std::slice::from_raw_parts(ptr, len)
}
&self.items
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-lua"
version = "1.1.0"
version = "1.1.2"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
@@ -31,7 +31,7 @@ serde_json = "1.0"
semver = "1"
# HTTP client for plugins
reqwest = { version = "0.13", features = ["blocking", "json"] }
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "blocking", "json"] }
# Math expression evaluation
meval = "0.2"

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry-rune"
version = "1.1.0"
version = "1.1.2"
edition = "2024"
rust-version = "1.90"
description = "Rune scripting runtime for owlry plugins"
@@ -22,7 +22,7 @@ log = "0.4"
env_logger = "0.11"
# HTTP client for network API
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"] }
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "blocking"] }
# Serialization
serde = { version = "1", features = ["derive"] }

View File

@@ -1,12 +1,11 @@
fn main() {
// Compile GResource bundle for icons
// Compile GResource bundle for plugin-specific icons (weather, media, pomodoro)
glib_build_tools::compile_resources(
&["src/resources/icons"],
"src/resources/icons.gresource.xml",
"icons.gresource",
);
// Rerun if icon files change
println!("cargo:rerun-if-changed=src/resources/icons.gresource.xml");
println!("cargo:rerun-if-changed=src/resources/icons/");
}

View File

@@ -507,9 +507,7 @@ impl MainWindow {
search_entry.set_placeholder_text(Some(&format!("Filter {} actions...", display_name)));
// Display actions
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
results_list.remove_all();
for item in &actions {
let row = ResultRow::new(item, "");
@@ -589,9 +587,7 @@ impl MainWindow {
.collect();
// Clear and repopulate
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
results_list.remove_all();
for item in &filtered {
let row = ResultRow::new(item, "");
@@ -702,9 +698,7 @@ impl MainWindow {
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);
}
results_list_cb.remove_all();
let items = result.items;
let initial_count =
@@ -739,9 +733,7 @@ impl MainWindow {
tag.as_deref(),
);
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
results_list.remove_all();
let initial_count = INITIAL_RESULTS.min(results.len());
@@ -1247,9 +1239,7 @@ impl MainWindow {
);
// Clear existing results
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
results_list.remove_all();
let initial_count = INITIAL_RESULTS.min(results.len());

View File

@@ -0,0 +1,967 @@
# Codebase Hardening Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix 15 soundness, security, robustness, and quality issues across owlry core and owlry-plugins repos.
**Architecture:** Point fixes organized into 5 severity tiers. Each tier is one commit. Core repo (owlry) tiers 1-3 first, then plugins repo (owlry-plugins) tiers 4-5. No new features, no refactoring beyond what each fix requires.
**Tech Stack:** Rust 1.90+, abi_stable 0.11, toml 0.8, dirs 5.0
**Repos:**
- Core: `/home/cnachtigall/ssd/git/archive/owlibou/owlry`
- Plugins: `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins`
---
## Task 1: Tier 1 — Critical / Soundness (owlry core)
**Files:**
- Modify: `crates/owlry-plugin-api/src/lib.rs:297-320`
- Modify: `crates/owlry-core/src/server.rs:1-6,91-123,127-215`
### 1a. Replace `static mut HOST_API` with `OnceLock`
- [ ] **Step 1: Replace the static mut and init function**
In `crates/owlry-plugin-api/src/lib.rs`, replace lines 297-320:
```rust
// Old:
// static mut HOST_API: Option<&'static HostAPI> = None;
//
// pub unsafe fn init_host_api(api: &'static HostAPI) {
// unsafe {
// HOST_API = Some(api);
// }
// }
//
// pub fn host_api() -> Option<&'static HostAPI> {
// unsafe { HOST_API }
// }
// New:
use std::sync::OnceLock;
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) {
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> {
HOST_API.get().copied()
}
```
Note: `init_host_api` keeps its `unsafe` signature for API compatibility even though `OnceLock::set` is safe. The `unsafe` documents the caller contract.
- [ ] **Step 2: Verify the plugin-api crate compiles**
Run: `cargo check -p owlry-plugin-api`
Expected: success, no warnings about `static mut`
### 1b. Add IPC message size limit
- [ ] **Step 3: Add size-limited read loop in server.rs**
In `crates/owlry-core/src/server.rs`, add the constant near the top of the file (after the imports):
```rust
/// Maximum size of a single IPC request line (1 MB)
const MAX_REQUEST_SIZE: usize = 1_048_576;
```
Replace the `handle_client` method (lines 91-123). Change the `for line in reader.lines()` loop to a manual `read_line` loop with size checking:
```rust
fn handle_client(
stream: UnixStream,
pm: Arc<Mutex<ProviderManager>>,
frecency: Arc<Mutex<FrecencyStore>>,
config: Arc<Config>,
) -> io::Result<()> {
let reader = BufReader::new(stream.try_clone()?);
let mut writer = stream;
let mut reader = reader;
let mut line = String::new();
loop {
line.clear();
let bytes_read = reader.read_line(&mut line)?;
if bytes_read == 0 {
break; // EOF
}
if line.len() > MAX_REQUEST_SIZE {
let resp = Response::Error {
message: "request too large".to_string(),
};
write_response(&mut writer, &resp)?;
break; // Drop connection
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let request: Request = match serde_json::from_str(trimmed) {
Ok(req) => req,
Err(e) => {
let resp = Response::Error {
message: format!("invalid request JSON: {}", e),
};
write_response(&mut writer, &resp)?;
continue;
}
};
let response = Self::handle_request(&request, &pm, &frecency, &config);
write_response(&mut writer, &response)?;
}
Ok(())
}
```
### 1c. Handle mutex poisoning gracefully
- [ ] **Step 4: Replace all lock().unwrap() in handle_request**
In `crates/owlry-core/src/server.rs`, in the `handle_request` method, replace every occurrence of `.lock().unwrap()` with `.lock().unwrap_or_else(|e| e.into_inner())`. There are instances in the `Query`, `Launch`, `Providers`, `Refresh`, `Submenu`, and `PluginAction` arms.
For example, the Query arm changes from:
```rust
let pm_guard = pm.lock().unwrap();
let frecency_guard = frecency.lock().unwrap();
```
to:
```rust
let pm_guard = pm.lock().unwrap_or_else(|e| e.into_inner());
let frecency_guard = frecency.lock().unwrap_or_else(|e| e.into_inner());
```
Apply this pattern to all `.lock().unwrap()` calls in `handle_request`.
- [ ] **Step 5: Build and test the core crate**
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
Expected: all checks pass, all existing tests pass
- [ ] **Step 6: Commit Tier 1**
```bash
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry
git add crates/owlry-plugin-api/src/lib.rs crates/owlry-core/src/server.rs
git commit -m "fix: soundness — OnceLock for HOST_API, IPC size limits, mutex poisoning recovery"
```
---
## Task 2: Tier 2 — Security (owlry core)
**Files:**
- Modify: `crates/owlry-core/src/server.rs:1-6,29-36,91-123`
- Modify: `crates/owlry-core/src/main.rs:26-32`
### 2a. Set socket permissions after bind
- [ ] **Step 1: Add permission setting in Server::bind**
In `crates/owlry-core/src/server.rs`, add the import at the top:
```rust
use std::os::unix::fs::PermissionsExt;
```
In `Server::bind()`, after the `UnixListener::bind(socket_path)?;` line, add:
```rust
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?;
```
### 2b. Log signal handler failure
- [ ] **Step 2: Replace .ok() with warning log in main.rs**
In `crates/owlry-core/src/main.rs`, add `use log::warn;` to the imports, then replace lines 26-32:
```rust
// Old:
// ctrlc::set_handler(move || {
// let _ = std::fs::remove_file(&sock_cleanup);
// std::process::exit(0);
// })
// .ok();
// New:
if let Err(e) = ctrlc::set_handler(move || {
let _ = std::fs::remove_file(&sock_cleanup);
std::process::exit(0);
}) {
warn!("Failed to set signal handler: {}", e);
}
```
### 2c. Add client read timeout
- [ ] **Step 3: Set read timeout on accepted connections**
In `crates/owlry-core/src/server.rs`, add `use std::time::Duration;` to the imports.
In the `handle_client` method, at the very top (before the `BufReader` creation), add:
```rust
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
```
This means the `stream` passed to `handle_client` needs to be mutable, or we set it on the clone. Since `set_read_timeout` takes `&self` (not `&mut self`), we can call it directly:
```rust
fn handle_client(
stream: UnixStream,
pm: Arc<...>,
frecency: Arc<...>,
config: Arc<Config>,
) -> io::Result<()> {
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
let reader = BufReader::new(stream.try_clone()?);
// ... rest unchanged
```
- [ ] **Step 4: Build and test**
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
Expected: all checks pass, all existing tests pass
- [ ] **Step 5: Commit Tier 2**
```bash
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry
git add crates/owlry-core/src/server.rs crates/owlry-core/src/main.rs
git commit -m "fix: security — socket perms 0600, signal handler logging, client read timeout"
```
---
## Task 3: Tier 3 — Robustness / Quality (owlry core)
**Files:**
- Modify: `crates/owlry-core/src/server.rs:1-6,17-23,53-73,91-215`
### 3a. Log malformed JSON requests
- [ ] **Step 1: Add warn! for JSON parse errors**
In `crates/owlry-core/src/server.rs`, in the `handle_client` method, in the JSON parse error arm, add a warning log before the error response:
```rust
Err(e) => {
warn!("Malformed request from client: {}", e);
let resp = Response::Error {
message: format!("invalid request JSON: {}", e),
};
write_response(&mut writer, &resp)?;
continue;
}
```
### 3b. Replace Mutex with RwLock
- [ ] **Step 2: Change Server struct and imports**
In `crates/owlry-core/src/server.rs`, change the import from `Mutex` to `RwLock`:
```rust
use std::sync::{Arc, RwLock};
```
Change the `Server` struct fields:
```rust
pub struct Server {
listener: UnixListener,
socket_path: PathBuf,
provider_manager: Arc<RwLock<ProviderManager>>,
frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>,
}
```
- [ ] **Step 3: Update Server::bind**
In `Server::bind()`, change `Arc::new(Mutex::new(...))` to `Arc::new(RwLock::new(...))`:
```rust
Ok(Self {
listener,
socket_path: socket_path.to_path_buf(),
provider_manager: Arc::new(RwLock::new(provider_manager)),
frecency: Arc::new(RwLock::new(frecency)),
config: Arc::new(config),
})
```
- [ ] **Step 4: Update handle_client and handle_request signatures**
Change `handle_client` parameter types:
```rust
fn handle_client(
stream: UnixStream,
pm: Arc<RwLock<ProviderManager>>,
frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>,
) -> io::Result<()> {
```
Change `handle_request` parameter types:
```rust
fn handle_request(
request: &Request,
pm: &Arc<RwLock<ProviderManager>>,
frecency: &Arc<RwLock<FrecencyStore>>,
config: &Arc<Config>,
) -> Response {
```
Also update `handle_one_for_testing` if it passes these types through.
- [ ] **Step 5: Update lock calls per request type**
In `handle_request`, change each lock call according to the read/write mapping:
**Query** (read PM, read frecency):
```rust
Request::Query { text, modes } => {
let filter = match modes {
Some(m) => ProviderFilter::from_mode_strings(m),
None => ProviderFilter::all(),
};
let max = config.general.max_results;
let weight = config.providers.frecency_weight;
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, &filter, &frecency_guard, weight, None,
);
Response::Results {
items: results
.into_iter()
.map(|(item, score)| launch_item_to_result(item, score))
.collect(),
}
}
```
**Launch** (write frecency):
```rust
Request::Launch { item_id, provider: _ } => {
let mut frecency_guard = frecency.write().unwrap_or_else(|e| e.into_inner());
frecency_guard.record_launch(item_id);
Response::Ack
}
```
**Providers** (read PM):
```rust
Request::Providers => {
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(),
}
}
```
**Refresh** (write PM):
```rust
Request::Refresh { provider } => {
let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner());
pm_guard.refresh_provider(provider);
Response::Ack
}
```
**Toggle** (no locks):
```rust
Request::Toggle => Response::Ack,
```
**Submenu** (read PM):
```rust
Request::Submenu { plugin_id, data } => {
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
.into_iter()
.map(|item| launch_item_to_result(item, 0))
.collect(),
},
None => Response::Error {
message: format!("no submenu actions for plugin '{}'", plugin_id),
},
}
}
```
**PluginAction** (read PM):
```rust
Request::PluginAction { command } => {
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
if pm_guard.execute_plugin_action(command) {
Response::Ack
} else {
Response::Error {
message: format!("no plugin handled action '{}'", command),
}
}
}
```
- [ ] **Step 6: Build and test**
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
Expected: all checks pass, all existing tests pass
- [ ] **Step 7: Commit Tier 3**
```bash
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry
git add crates/owlry-core/src/server.rs
git commit -m "fix: robustness — RwLock for concurrent reads, log malformed JSON requests"
```
---
## Task 4: Tier 4 — Critical fixes (owlry-plugins)
**Files:**
- Modify: `crates/owlry-plugin-converter/src/currency.rs:88-113,244-265`
- Modify: `crates/owlry-plugin-converter/src/units.rs:90-101,160-213`
- Modify: `crates/owlry-plugin-bookmarks/src/lib.rs:40-45,228-260,317-353`
All paths relative to `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins`.
### 4a. Fix Box::leak memory leak in converter
- [ ] **Step 1: Change resolve_currency_code return type**
In `crates/owlry-plugin-converter/src/currency.rs`, change the `resolve_currency_code` function (line 88) from returning `Option<String>` to `Option<&'static str>`:
```rust
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);
}
}
// 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");
}
// Check if we have rates for it — return the matching alias code
if let Some(rates) = get_rates()
&& rates.rates.contains_key(&upper)
{
// Find a matching CURRENCY_ALIASES entry for this code
for ca in CURRENCY_ALIASES {
if ca.code == upper {
return Some(ca.code);
}
}
// Not in our aliases but valid in ECB rates — we can't return
// a &'static str for an arbitrary code, so skip
}
}
None
}
```
Note: For ISO codes that are in ECB rates but NOT in `CURRENCY_ALIASES`, we lose the ability to resolve them. This is acceptable because:
1. `CURRENCY_ALIASES` already covers the 15 most common currencies
2. The alternative (Box::leak) was leaking memory on every keystroke
- [ ] **Step 2: Update is_currency_alias**
No change needed — it already just calls `resolve_currency_code(alias).is_some()`.
- [ ] **Step 3: Update find_unit in units.rs**
In `crates/owlry-plugin-converter/src/units.rs`, replace lines 90-101:
```rust
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);
}
// Check currency — resolve_currency_code now returns &'static str directly
currency::resolve_currency_code(&lower)
}
```
- [ ] **Step 4: Update convert_currency in units.rs**
In `crates/owlry-plugin-converter/src/units.rs`, update `convert_currency` (line 160). The `from_code` and `to_code` are now `&'static str`. HashMap lookups with `rates.rates.get(code)` work because `HashMap<String, f64>::get` accepts `&str` via `Borrow`:
```rust
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))
}
```
- [ ] **Step 5: Update convert_currency_common in units.rs**
In `crates/owlry-plugin-converter/src/units.rs`, update `convert_currency_common` (line 180). Change `from_code` handling:
```rust
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()
}
```
- [ ] **Step 6: Update currency tests**
In `crates/owlry-plugin-converter/src/currency.rs`, update test assertions to use `&str` instead of `String`:
```rust
#[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);
}
```
### 4b. Fix bookmarks temp file race condition
- [ ] **Step 7: Use PID-based temp filenames**
In `crates/owlry-plugin-bookmarks/src/lib.rs`, replace the `read_firefox_bookmarks` method. Change lines 318-319 and the corresponding favicons temp path:
```rust
fn read_firefox_bookmarks(places_path: &PathBuf, items: &mut Vec<PluginItem>) {
let temp_dir = std::env::temp_dir();
let pid = std::process::id();
let temp_db = temp_dir.join(format!("owlry_places_{}.sqlite", pid));
// Copy database to temp location to avoid locking issues
if fs::copy(places_path, &temp_db).is_err() {
return;
}
// Also copy WAL file if it exists
let wal_path = places_path.with_extension("sqlite-wal");
if wal_path.exists() {
let temp_wal = temp_db.with_extension("sqlite-wal");
let _ = fs::copy(&wal_path, &temp_wal);
}
// Copy favicons database if available
let favicons_path = Self::firefox_favicons_path(places_path);
let temp_favicons = temp_dir.join(format!("owlry_favicons_{}.sqlite", pid));
if let Some(ref fp) = favicons_path {
let _ = fs::copy(fp, &temp_favicons);
let fav_wal = fp.with_extension("sqlite-wal");
if fav_wal.exists() {
let _ = fs::copy(&fav_wal, temp_favicons.with_extension("sqlite-wal"));
}
}
let cache_dir = Self::ensure_favicon_cache_dir();
// Read bookmarks from places.sqlite
let bookmarks = Self::fetch_firefox_bookmarks(&temp_db, &temp_favicons, cache_dir.as_ref());
// Clean up temp files
let _ = fs::remove_file(&temp_db);
let _ = fs::remove_file(temp_db.with_extension("sqlite-wal"));
let _ = fs::remove_file(&temp_favicons);
let _ = fs::remove_file(temp_favicons.with_extension("sqlite-wal"));
// ... rest of method unchanged (the for loop adding items)
```
### 4c. Fix bookmarks background refresh never updating state
- [ ] **Step 8: Change BookmarksState to use Arc<Mutex<Vec<PluginItem>>>**
In `crates/owlry-plugin-bookmarks/src/lib.rs`, add `use std::sync::Mutex;` to imports (it's already importing `Arc` and `AtomicBool`).
Change the struct:
```rust
struct BookmarksState {
/// Cached bookmark items (shared with background thread)
items: Arc<Mutex<Vec<PluginItem>>>,
/// Flag to prevent concurrent background loads
loading: Arc<AtomicBool>,
}
impl BookmarksState {
fn new() -> Self {
Self {
items: Arc::new(Mutex::new(Vec::new())),
loading: Arc::new(AtomicBool::new(false)),
}
}
```
- [ ] **Step 9: Update load_bookmarks to write through Arc<Mutex>**
Update the `load_bookmarks` method:
```rust
fn load_bookmarks(&self) {
// Fast path: load from cache immediately if items are empty
{
let mut items = self.items.lock().unwrap_or_else(|e| e.into_inner());
if items.is_empty() {
*items = Self::load_cached_bookmarks();
}
}
// Don't start another background load if one is already running
if self.loading.swap(true, Ordering::SeqCst) {
return;
}
// Spawn background thread to refresh bookmarks
let loading = self.loading.clone();
let items_ref = self.items.clone();
thread::spawn(move || {
let mut new_items = Vec::new();
// Load Chrome/Chromium bookmarks (fast - just JSON parsing)
for path in Self::chromium_bookmark_paths() {
if path.exists() {
Self::read_chrome_bookmarks_static(&path, &mut new_items);
}
}
// Load Firefox bookmarks with favicons (synchronous with rusqlite)
for path in Self::firefox_places_paths() {
Self::read_firefox_bookmarks(&path, &mut new_items);
}
// Save to cache for next startup
Self::save_cached_bookmarks(&new_items);
// Update shared state so next refresh returns fresh data
if let Ok(mut items) = items_ref.lock() {
*items = new_items;
}
loading.store(false, Ordering::SeqCst);
});
}
```
Note: `load_bookmarks` now takes `&self` instead of `&mut self`.
- [ ] **Step 10: Update provider_refresh to read from Arc<Mutex>**
Update the `provider_refresh` function:
```rust
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<BookmarksState>
let state = unsafe { &*(handle.ptr as *const BookmarksState) };
// Load bookmarks
state.load_bookmarks();
// Return items
let items = state.items.lock().unwrap_or_else(|e| e.into_inner());
items.to_vec().into()
}
```
Note: Uses `&*` (shared ref) instead of `&mut *` since `load_bookmarks` now takes `&self`.
- [ ] **Step 11: Build and test plugins**
Run: `cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins && cargo check && cargo test`
Expected: all checks pass, all existing tests pass
- [ ] **Step 12: Commit Tier 4**
```bash
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins
git add crates/owlry-plugin-converter/src/currency.rs crates/owlry-plugin-converter/src/units.rs crates/owlry-plugin-bookmarks/src/lib.rs
git commit -m "fix: critical — eliminate Box::leak in converter, secure temp files, fix background refresh"
```
---
## Task 5: Tier 5 — Quality fixes (owlry-plugins)
**Files:**
- Modify: `crates/owlry-plugin-ssh/Cargo.toml`
- Modify: `crates/owlry-plugin-ssh/src/lib.rs:17-48`
- Modify: `crates/owlry-plugin-websearch/Cargo.toml`
- Modify: `crates/owlry-plugin-websearch/src/lib.rs:46-76,174-177`
- Modify: `crates/owlry-plugin-emoji/src/lib.rs:34-37,463-481`
- Modify: `crates/owlry-plugin-calculator/src/lib.rs:139`
- Modify: `crates/owlry-plugin-converter/src/lib.rs:95`
All paths relative to `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins`.
### 5a. SSH plugin: read terminal from config
- [ ] **Step 1: Add toml dependency to SSH plugin**
In `crates/owlry-plugin-ssh/Cargo.toml`, add:
```toml
# TOML config parsing
toml = "0.8"
```
- [ ] **Step 2: Add config loading and update SshState::new**
In `crates/owlry-plugin-ssh/src/lib.rs`, add `use std::fs;` to imports, remove the `DEFAULT_TERMINAL` constant, and update `SshState::new`:
```rust
impl SshState {
fn new() -> Self {
let terminal = Self::load_terminal_from_config();
Self {
items: Vec::new(),
terminal_command: terminal,
}
}
fn load_terminal_from_config() -> String {
// Try [plugins.ssh] in config.toml
let config_path = dirs::config_dir().map(|d| d.join("owlry").join("config.toml"));
if let Some(content) = config_path.and_then(|p| fs::read_to_string(p).ok())
&& let Ok(toml) = content.parse::<toml::Table>()
{
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
&& let Some(ssh) = plugins.get("ssh").and_then(|v| v.as_table())
&& let Some(terminal) = ssh.get("terminal").and_then(|v| v.as_str())
{
return terminal.to_string();
}
}
// Fall back to $TERMINAL env var
if let Ok(terminal) = std::env::var("TERMINAL") {
return terminal;
}
// Last resort
"xdg-terminal-exec".to_string()
}
```
### 5b. WebSearch plugin: read engine from config
- [ ] **Step 3: Add dependencies to websearch plugin**
In `crates/owlry-plugin-websearch/Cargo.toml`, add:
```toml
# TOML config parsing
toml = "0.8"
# XDG directories for config
dirs = "5.0"
```
- [ ] **Step 4: Add config loading and update provider_init**
In `crates/owlry-plugin-websearch/src/lib.rs`, add `use std::fs;` to imports. Add a config loading function and update `provider_init`:
```rust
fn load_engine_from_config() -> String {
let config_path = dirs::config_dir().map(|d| d.join("owlry").join("config.toml"));
if let Some(content) = config_path.and_then(|p| fs::read_to_string(p).ok())
&& let Ok(toml) = content.parse::<toml::Table>()
{
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
&& let Some(websearch) = plugins.get("websearch").and_then(|v| v.as_table())
&& let Some(engine) = websearch.get("engine").and_then(|v| v.as_str())
{
return engine.to_string();
}
}
DEFAULT_ENGINE.to_string()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let engine = load_engine_from_config();
let state = Box::new(WebSearchState::with_engine(&engine));
ProviderHandle::from_box(state)
}
```
Remove the TODO comment from the old `provider_init`.
### 5c. Emoji plugin: build items once at init
- [ ] **Step 5: Move load_emojis to constructor**
In `crates/owlry-plugin-emoji/src/lib.rs`, change `EmojiState::new` to call `load_emojis`:
```rust
impl EmojiState {
fn new() -> Self {
let mut state = Self { items: Vec::new() };
state.load_emojis();
state
}
```
Update `provider_refresh` to just return the cached items without reloading:
```rust
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<EmojiState>
let state = unsafe { &*(handle.ptr as *const EmojiState) };
// Return cached items (loaded once at init)
state.items.to_vec().into()
}
```
Note: Uses `&*` (shared ref) since we're only reading.
### 5d. Calculator/Converter: safer shell commands
- [ ] **Step 6: Fix calculator command**
In `crates/owlry-plugin-calculator/src/lib.rs`, in `evaluate_expression` (around line 139), replace:
```rust
// Old:
format!("sh -c 'echo -n \"{}\" | wl-copy'", result_str)
// New:
format!("printf '%s' '{}' | wl-copy", result_str.replace('\'', "'\\''"))
```
- [ ] **Step 7: Fix converter command**
In `crates/owlry-plugin-converter/src/lib.rs`, in `provider_query` (around line 95), replace:
```rust
// Old:
format!("sh -c 'echo -n \"{}\" | wl-copy'", r.raw_value)
// New:
format!("printf '%s' '{}' | wl-copy", r.raw_value.replace('\'', "'\\''"))
```
- [ ] **Step 8: Build and test all plugins**
Run: `cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins && cargo check && cargo test`
Expected: all checks pass, all existing tests pass
- [ ] **Step 9: Commit Tier 5**
```bash
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins
git add crates/owlry-plugin-ssh/Cargo.toml crates/owlry-plugin-ssh/src/lib.rs \
crates/owlry-plugin-websearch/Cargo.toml crates/owlry-plugin-websearch/src/lib.rs \
crates/owlry-plugin-emoji/src/lib.rs \
crates/owlry-plugin-calculator/src/lib.rs \
crates/owlry-plugin-converter/src/lib.rs
git commit -m "fix: quality — config-based terminal/engine, emoji init perf, safer shell commands"
```

View File

@@ -0,0 +1,810 @@
# Script Runtime Integration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Enable the owlry-core daemon to discover and load Lua/Rune user plugins from `~/.config/owlry/plugins/`, with automatic hot-reload on file changes.
**Architecture:** Fix ABI mismatches between core and runtimes, wire `LoadedRuntime` into `ProviderManager::new_with_config()`, add filesystem watcher for automatic plugin reload. Runtimes are external `.so` libraries loaded from `/usr/lib/owlry/runtimes/`.
**Tech Stack:** Rust 1.90+, notify 7, notify-debouncer-mini 0.5, libloading 0.8
**Repos:**
- Core: `/home/cnachtigall/ssd/git/archive/owlibou/owlry`
- Plugins (docs only): `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins`
---
## Task 1: Fix Lua RuntimeInfo ABI and vtable init signature
**Files:**
- Modify: `crates/owlry-lua/src/lib.rs:42-74,260-279,322-336`
- Modify: `crates/owlry-rune/src/lib.rs:42-46,73-84,90-95,97-146,215-229`
- Modify: `crates/owlry-core/src/plugins/runtime_loader.rs:55-68,84-146,267-277`
### 1a. Shrink Lua RuntimeInfo to 2 fields
- [ ] **Step 1: Update RuntimeInfo struct and runtime_info() in owlry-lua**
In `crates/owlry-lua/src/lib.rs`:
Remove the `LUA_RUNTIME_API_VERSION` constant (line 43).
Replace the `RuntimeInfo` struct (lines 67-74):
```rust
/// Runtime info returned by the runtime
#[repr(C)]
pub struct RuntimeInfo {
pub name: RString,
pub version: RString,
}
```
Replace `runtime_info()` (lines 260-268):
```rust
extern "C" fn runtime_info() -> RuntimeInfo {
RuntimeInfo {
name: RString::from("Lua"),
version: RString::from(env!("CARGO_PKG_VERSION")),
}
}
```
Remove unused constants `RUNTIME_ID` and `RUNTIME_DESCRIPTION` (lines 37, 40) if no longer referenced.
### 1b. Add owlry_version parameter to vtable init
- [ ] **Step 2: Update ScriptRuntimeVTable in core**
In `crates/owlry-core/src/plugins/runtime_loader.rs`, change the `init` field (line 59):
```rust
pub struct ScriptRuntimeVTable {
pub info: extern "C" fn() -> RuntimeInfo,
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(
handle: RuntimeHandle,
provider_id: RStr<'_>,
query: RStr<'_>,
) -> RVec<PluginItem>,
pub drop: extern "C" fn(handle: RuntimeHandle),
}
```
- [ ] **Step 3: Update LoadedRuntime to pass version**
In `crates/owlry-core/src/plugins/runtime_loader.rs`, update `load_lua`, `load_rune`, and `load_from_path` to accept and pass the version:
```rust
impl LoadedRuntime {
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,
)
}
fn load_from_path(
name: &'static str,
library_path: &Path,
vtable_symbol: &[u8],
plugins_dir: &Path,
owlry_version: &str,
) -> PluginResult<Self> {
// ... existing library loading code ...
// Initialize the runtime with version
let plugins_dir_str = plugins_dir.to_string_lossy();
let handle = (vtable.init)(
RStr::from_str(&plugins_dir_str),
RStr::from_str(owlry_version),
);
// ... rest unchanged ...
}
}
impl LoadedRuntime {
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,
)
}
}
```
- [ ] **Step 4: Update Lua runtime_init to accept version**
In `crates/owlry-lua/src/lib.rs`, update `runtime_init` (line 270) and the vtable:
```rust
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));
state.discover_and_load(owlry_version.as_str());
RuntimeHandle::from_box(state)
}
```
Update the `LuaRuntimeVTable` struct `init` field to match:
```rust
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
```
- [ ] **Step 5: Update Rune runtime_init to accept version**
In `crates/owlry-rune/src/lib.rs`, update `runtime_init` (line 97) and the vtable:
```rust
extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle {
let _ = env_logger::try_init();
let plugins_dir = PathBuf::from(plugins_dir.as_str());
let _version = owlry_version.as_str();
log::info!(
"Initializing Rune runtime with plugins from: {}",
plugins_dir.display()
);
// ... rest unchanged — Rune doesn't currently do version checking ...
```
Update the `RuneRuntimeVTable` struct `init` field:
```rust
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
```
- [ ] **Step 6: Build all three crates**
Run: `cargo check -p owlry-core && cargo check -p owlry-lua && cargo check -p owlry-rune`
Expected: all pass
- [ ] **Step 7: Run tests**
Run: `cargo test -p owlry-core && cargo test -p owlry-lua && cargo test -p owlry-rune`
Expected: all pass
- [ ] **Step 8: Commit**
```bash
git add crates/owlry-core/src/plugins/runtime_loader.rs \
crates/owlry-lua/src/lib.rs \
crates/owlry-rune/src/lib.rs
git commit -m "fix: align runtime ABI — shrink Lua RuntimeInfo, pass owlry_version to init"
```
---
## Task 2: Change default entry points to `main` and add alias
**Files:**
- Modify: `crates/owlry-lua/src/manifest.rs:52-54`
- Modify: `crates/owlry-rune/src/manifest.rs:36-38,29`
- [ ] **Step 1: Update Lua manifest default entry**
In `crates/owlry-lua/src/manifest.rs`, change `default_entry()` (line 52):
```rust
fn default_entry() -> String {
"main.lua".to_string()
}
```
Add `serde(alias)` to the `entry` field in `PluginInfo` (line 45):
```rust
#[serde(default = "default_entry", alias = "entry_point")]
pub entry: String,
```
- [ ] **Step 2: Update Rune manifest default entry**
In `crates/owlry-rune/src/manifest.rs`, change `default_entry()` (line 36):
```rust
fn default_entry() -> String {
"main.rn".to_string()
}
```
Add `serde(alias)` to the `entry` field in `PluginInfo` (line 29):
```rust
#[serde(default = "default_entry", alias = "entry_point")]
pub entry: String,
```
- [ ] **Step 3: Update tests that reference init.lua/init.rn**
In `crates/owlry-lua/src/manifest.rs` test `test_parse_minimal_manifest`:
```rust
assert_eq!(manifest.plugin.entry, "main.lua");
```
In `crates/owlry-lua/src/loader.rs` test `create_test_plugin`:
```rust
fs::write(plugin_dir.join("main.lua"), "-- empty plugin").unwrap();
```
In `crates/owlry-rune/src/manifest.rs` test `test_parse_minimal_manifest`:
```rust
assert_eq!(manifest.plugin.entry, "main.rn");
```
- [ ] **Step 4: Build and test**
Run: `cargo test -p owlry-lua && cargo test -p owlry-rune`
Expected: all pass
- [ ] **Step 5: Commit**
```bash
git add crates/owlry-lua/src/manifest.rs crates/owlry-lua/src/loader.rs \
crates/owlry-rune/src/manifest.rs
git commit -m "feat: change default entry points to main.lua/main.rn, add entry_point alias"
```
---
## Task 3: Wire runtime loading into ProviderManager
**Files:**
- Modify: `crates/owlry-core/src/providers/mod.rs:106-119,173-224`
- Modify: `crates/owlry-core/src/plugins/runtime_loader.rs:13` (remove allow dead_code)
- [ ] **Step 1: Add runtimes field to ProviderManager**
In `crates/owlry-core/src/providers/mod.rs`, add import and field:
```rust
use crate::plugins::runtime_loader::LoadedRuntime;
```
Add to the `ProviderManager` struct (after `matcher` field):
```rust
pub struct ProviderManager {
providers: Vec<Box<dyn Provider>>,
static_native_providers: Vec<NativeProvider>,
dynamic_providers: Vec<NativeProvider>,
widget_providers: Vec<NativeProvider>,
matcher: SkimMatcherV2,
/// Loaded script runtimes (Lua, Rune) — must stay alive to keep Library handles
runtimes: Vec<LoadedRuntime>,
/// Type IDs of providers that came from script runtimes (for hot-reload removal)
runtime_type_ids: std::collections::HashSet<String>,
}
```
Update `ProviderManager::new()` to initialize the new fields:
```rust
let mut manager = Self {
providers: core_providers,
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(),
};
```
- [ ] **Step 2: Add runtime loading to new_with_config**
In `ProviderManager::new_with_config()`, after the native plugin loading block (after line 221) and before `Self::new(core_providers, native_providers)` (line 223), add runtime loading:
```rust
// 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");
if 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);
}
}
}
let mut manager = Self::new(core_providers, native_providers);
manager.runtimes = runtimes;
manager.runtime_type_ids = runtime_type_ids;
// Add runtime providers to the core providers list
for provider in runtime_providers {
info!("Registered runtime provider: {}", provider.name());
manager.providers.push(provider);
}
// Refresh runtime providers
for provider in &mut manager.providers {
// Only refresh the ones we just added (runtime providers)
// They need an initial refresh to populate items
}
manager.refresh_all();
manager
```
Note: This replaces the current `Self::new(core_providers, native_providers)` return. The `refresh_all()` at the end of `new()` will be called, plus we call it again — but that's fine since refresh is idempotent. Actually, `new()` already calls `refresh_all()`, so we should remove the duplicate. Let me adjust:
The cleaner approach is to construct the manager via `Self::new()` which calls `refresh_all()`, then set the runtime fields and add providers, then call `refresh_all()` once more for the newly added runtime providers. Or better — add runtime providers to `core_providers` before calling `new()`:
```rust
// Merge runtime providers into core providers
let mut all_core_providers = core_providers;
for provider in runtime_providers {
info!("Registered runtime provider: {}", provider.name());
all_core_providers.push(provider);
}
let mut manager = Self::new(all_core_providers, native_providers);
manager.runtimes = runtimes;
manager.runtime_type_ids = runtime_type_ids;
manager
```
This way `new()` handles the single `refresh_all()` call.
- [ ] **Step 3: Remove allow(dead_code) from runtime_loader**
In `crates/owlry-core/src/plugins/runtime_loader.rs`, remove `#![allow(dead_code)]` (line 13).
Fix any resulting dead code warnings by removing unused `#[allow(dead_code)]` attributes on individual items that are now actually used, or adding targeted `#[allow(dead_code)]` only on truly unused items.
- [ ] **Step 4: Build and test**
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
Expected: all pass. May see info logs about runtimes loading (if installed on the build machine).
- [ ] **Step 5: Commit**
```bash
git add crates/owlry-core/src/providers/mod.rs \
crates/owlry-core/src/plugins/runtime_loader.rs
git commit -m "feat: wire script runtime loading into daemon ProviderManager"
```
---
## Task 4: Filesystem watcher for hot-reload
**Files:**
- Create: `crates/owlry-core/src/plugins/watcher.rs`
- Modify: `crates/owlry-core/src/plugins/mod.rs:23-28` (add module)
- Modify: `crates/owlry-core/src/providers/mod.rs` (add reload method)
- Modify: `crates/owlry-core/src/server.rs:59-78` (start watcher)
- Modify: `crates/owlry-core/Cargo.toml` (add deps)
- [ ] **Step 1: Add dependencies**
In `crates/owlry-core/Cargo.toml`, add to `[dependencies]`:
```toml
# Filesystem watching for plugin hot-reload
notify = "7"
notify-debouncer-mini = "0.5"
```
- [ ] **Step 2: Add reload_runtimes method to ProviderManager**
In `crates/owlry-core/src/providers/mod.rs`, add a method:
```rust
/// Reload all script runtime providers (called by filesystem watcher)
pub fn reload_runtimes(&mut self) {
// 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
self.runtimes.clear();
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");
}
```
- [ ] **Step 3: Create the watcher module**
Create `crates/owlry-core/src/plugins/watcher.rs`:
```rust
//! 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::{DebouncedEventKind, new_debouncer};
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.
///
/// If the plugins directory doesn't exist or the watcher fails to start,
/// logs a warning and returns without spawning a thread.
pub fn start_watching(pm: Arc<RwLock<ProviderManager>>) {
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() {
// Create the directory so the watcher has something to watch
if std::fs::create_dir_all(&plugins_dir).is_err() {
warn!("Failed to create plugins directory: {}", plugins_dir.display());
return;
}
}
thread::spawn(move || {
if let Err(e) = watch_loop(&plugins_dir, &pm) {
warn!("Plugin watcher stopped: {}", e);
}
});
info!("Plugin file watcher started for {}", plugins_dir.display());
}
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());
loop {
match rx.recv() {
Ok(Ok(events)) => {
// Check if any event is relevant (not just access/metadata)
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(errors)) => {
for e in errors {
warn!("File watcher error: {}", e);
}
}
Err(e) => {
// Channel closed — watcher was dropped
return Err(Box::new(e));
}
}
}
}
```
- [ ] **Step 4: Register the watcher module**
In `crates/owlry-core/src/plugins/mod.rs`, add after line 28 (`pub mod runtime_loader;`):
```rust
pub mod watcher;
```
- [ ] **Step 5: Start watcher in Server::run**
In `crates/owlry-core/src/server.rs`, in the `run()` method, before the accept loop, add:
```rust
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() {
```
- [ ] **Step 6: Build and test**
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
Expected: all pass
- [ ] **Step 7: Manual smoke test**
```bash
# Start the daemon
RUST_LOG=info cargo run -p owlry-core
# In another terminal, create a test plugin
mkdir -p ~/.config/owlry/plugins/hotreload-test
cat > ~/.config/owlry/plugins/hotreload-test/plugin.toml << 'EOF'
[plugin]
id = "hotreload-test"
name = "Hot Reload Test"
version = "0.1.0"
EOF
cat > ~/.config/owlry/plugins/hotreload-test/main.lua << 'EOF'
owlry.provider.register({
name = "hotreload-test",
refresh = function()
return {{ id = "hr1", name = "Hot Reload Works!", command = "echo yes" }}
end,
})
EOF
# Watch daemon logs — should see "Plugin file change detected, reloading runtimes..."
# Clean up after testing
rm -rf ~/.config/owlry/plugins/hotreload-test
```
- [ ] **Step 8: Commit**
```bash
git add crates/owlry-core/Cargo.toml \
crates/owlry-core/src/plugins/watcher.rs \
crates/owlry-core/src/plugins/mod.rs \
crates/owlry-core/src/providers/mod.rs \
crates/owlry-core/src/server.rs
git commit -m "feat: add filesystem watcher for automatic user plugin hot-reload"
```
---
## Task 5: Update plugin development documentation
**Files:**
- Modify: `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins/docs/PLUGIN_DEVELOPMENT.md`
- [ ] **Step 1: Update Lua plugin section**
In `docs/PLUGIN_DEVELOPMENT.md`, update the Lua Quick Start section (around line 101):
Change `entry_point = "init.lua"` to `entry = "main.lua"` in the manifest example.
Replace the Lua code example with the `owlry.provider.register()` API:
```lua
owlry.provider.register({
name = "myluaprovider",
display_name = "My Lua Provider",
type_id = "mylua",
default_icon = "application-x-executable",
prefix = ":mylua",
refresh = function()
return {
{ id = "item-1", name = "Hello from Lua", command = "echo 'Hello Lua!'" },
}
end,
})
```
Remove `local owlry = require("owlry")` — the `owlry` table is pre-registered globally.
- [ ] **Step 2: Update Rune plugin section**
Update the Rune manifest example to use `entry = "main.rn"` instead of `entry_point = "main.rn"`.
- [ ] **Step 3: Update manifest reference**
In the Lua Plugin API manifest section (around line 325), change `entry_point` to `entry` and add a note:
```toml
[plugin]
id = "my-plugin"
name = "My Plugin"
version = "1.0.0"
description = "Plugin description"
entry = "main.lua" # Default: main.lua (Lua) / main.rn (Rune)
# Alias: entry_point also accepted
owlry_version = ">=1.0.0" # Optional version constraint
```
- [ ] **Step 4: Add hot-reload documentation**
Add a new section after "Best Practices" (before "Publishing to AUR"):
```markdown
## Hot Reload
User plugins in `~/.config/owlry/plugins/` are automatically reloaded when files change.
The daemon watches the plugins directory and reloads all script runtimes when any file
is created, modified, or deleted. No daemon restart is needed.
**What triggers a reload:**
- Creating a new plugin directory with `plugin.toml`
- Editing a plugin's script files (`main.lua`, `main.rn`, etc.)
- Editing a plugin's `plugin.toml`
- Deleting a plugin directory
**What does NOT trigger a reload:**
- Changes to native plugins (`.so` files) — these require a daemon restart
- Changes to runtime libraries in `/usr/lib/owlry/runtimes/` — daemon restart needed
**Reload behavior:**
- All script runtimes (Lua, Rune) are fully reloaded
- Existing search results may briefly show stale data during reload
- Errors in plugins are logged but don't affect other plugins
```
- [ ] **Step 5: Update Lua provider functions section**
Replace the bare `refresh()`/`query()` examples (around line 390) with the register API:
```lua
-- Static provider: called once at startup and on reload
owlry.provider.register({
name = "my-provider",
display_name = "My Provider",
prefix = ":my",
refresh = function()
return {
{ id = "id1", name = "Item 1", command = "command1" },
{ id = "id2", name = "Item 2", command = "command2" },
}
end,
})
-- Dynamic provider: called on each keystroke
owlry.provider.register({
name = "my-search",
display_name = "My Search",
prefix = "?my",
query = function(q)
if q == "" then return {} end
return {
{ id = "result", name = "Result for: " .. q, command = "echo " .. q },
}
end,
})
```
- [ ] **Step 6: Commit**
```bash
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins
git add docs/PLUGIN_DEVELOPMENT.md
git commit -m "docs: update plugin development guide for main.lua/rn defaults, register API, hot-reload"
```
---
## Task 6: Update hello-test plugin and clean up
**Files:**
- Modify: `~/.config/owlry/plugins/hello-test/plugin.toml`
- Modify: `~/.config/owlry/plugins/hello-test/init.lua` → rename to `main.lua`
This is a local-only task, not committed to either repo.
- [ ] **Step 1: Update hello-test plugin**
```bash
# Rename entry point
mv ~/.config/owlry/plugins/hello-test/init.lua ~/.config/owlry/plugins/hello-test/main.lua
# Update manifest to use entry field
cat > ~/.config/owlry/plugins/hello-test/plugin.toml << 'EOF'
[plugin]
id = "hello-test"
name = "Hello Test"
version = "0.1.0"
description = "Minimal test plugin for verifying Lua runtime loading"
EOF
```
- [ ] **Step 2: End-to-end verification**
```bash
# Rebuild and restart daemon
cargo build -p owlry-core
RUST_LOG=info cargo run -p owlry-core
# Expected log output should include:
# - "Loaded Lua runtime with 1 provider(s)" (hello-test)
# - "Loaded Rune runtime with 1 provider(s)" (hyprshutdown)
# - "Plugin file watcher started for ..."
```

View File

@@ -0,0 +1,876 @@
# Config Editor Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Built-in `:config` provider that lets users browse and modify settings, toggle providers, select themes/engines, and manage profiles — all from within the launcher.
**Architecture:** The config editor is a `DynamicProvider` that interprets the query text as a navigation path. `:config providers` shows toggles, `:config theme` lists themes, `:config profile dev modes` shows a mode checklist. Actions (toggling, setting values) use the existing `PluginAction` IPC flow which keeps the window open and re-queries, giving instant visual feedback. Config changes are persisted to `config.toml` via `Config::save()`.
**Tech Stack:** Rust, owlry-core providers, toml serialization
---
## Key Design Decision: Query-as-Navigation
Instead of submenus, the `:config` prefix scopes the search bar as navigation:
```
:config → category list
:config providers → provider toggles
:config theme → theme selection
:config engine → search engine selection
:config frecency → frecency toggle + weight
:config profiles → profile list
:config profile dev → profile actions (edit modes, rename, delete)
:config profile dev modes → mode checklist for profile
:config profile create myname → create profile action
:config fontsize 16 → set font size action
:config width 900 → set width action
```
Actions use `CONFIG:*` commands dispatched via `execute_plugin_action`. Since this returns `false` for `should_close`, the window stays open and re-queries — the user sees updated state immediately.
## File Map
| File | Action | Responsibility |
|------|--------|----------------|
| `crates/owlry-core/src/providers/config_editor.rs` | Create | ConfigProvider: query parsing, result generation, action execution |
| `crates/owlry-core/src/providers/mod.rs` | Modify | Register ConfigProvider, extend action dispatch |
| `crates/owlry-core/src/config/mod.rs` | Modify | Add helper methods for config mutation |
---
### Task 1: Create ConfigProvider skeleton and register it
**Files:**
- Create: `crates/owlry-core/src/providers/config_editor.rs`
- Modify: `crates/owlry-core/src/providers/mod.rs`
- [ ] **Step 1: Add module declaration**
In `crates/owlry-core/src/providers/mod.rs`, add with the other module declarations:
```rust
pub(crate) mod config_editor;
```
- [ ] **Step 2: Create config_editor.rs with top-level categories**
Create `crates/owlry-core/src/providers/config_editor.rs`:
```rust
//! Built-in config editor provider.
//!
//! Lets users browse and modify settings from within the launcher.
//! Uses `:config` prefix with query-as-navigation pattern.
use std::sync::{Arc, RwLock};
use crate::config::Config;
use super::{DynamicProvider, LaunchItem, ProviderType};
const PROVIDER_TYPE_ID: &str = "config";
const PROVIDER_ICON: &str = "preferences-system-symbolic";
pub struct ConfigProvider {
config: Arc<RwLock<Config>>,
}
impl ConfigProvider {
pub fn new(config: Arc<RwLock<Config>>) -> Self {
Self { config }
}
/// Execute a CONFIG:* action command. Returns true if handled.
pub fn execute_action(&self, command: &str) -> bool {
let Some(action) = command.strip_prefix("CONFIG:") else {
return false;
};
let mut config = match self.config.write() {
Ok(c) => c,
Err(_) => return false,
};
let handled = self.handle_action(action, &mut config);
if handled {
if let Err(e) = config.save() {
log::warn!("Failed to save config: {}", e);
}
}
handled
}
fn handle_action(&self, action: &str, config: &mut Config) -> bool {
if let Some(key) = action.strip_prefix("toggle:") {
return self.toggle_bool(key, config);
}
if let Some(rest) = action.strip_prefix("set:") {
return self.set_value(rest, config);
}
if let Some(rest) = action.strip_prefix("profile:") {
return self.handle_profile_action(rest, config);
}
false
}
fn toggle_bool(&self, key: &str, config: &mut Config) -> bool {
match key {
"providers.applications" => { config.providers.applications = !config.providers.applications; true }
"providers.commands" => { config.providers.commands = !config.providers.commands; true }
"providers.calculator" => { config.providers.calculator = !config.providers.calculator; true }
"providers.converter" => { config.providers.converter = !config.providers.converter; true }
"providers.system" => { config.providers.system = !config.providers.system; true }
"providers.websearch" => { config.providers.websearch = !config.providers.websearch; true }
"providers.ssh" => { config.providers.ssh = !config.providers.ssh; true }
"providers.clipboard" => { config.providers.clipboard = !config.providers.clipboard; true }
"providers.bookmarks" => { config.providers.bookmarks = !config.providers.bookmarks; true }
"providers.emoji" => { config.providers.emoji = !config.providers.emoji; true }
"providers.scripts" => { config.providers.scripts = !config.providers.scripts; true }
"providers.files" => { config.providers.files = !config.providers.files; true }
"providers.uuctl" => { config.providers.uuctl = !config.providers.uuctl; true }
"providers.media" => { config.providers.media = !config.providers.media; true }
"providers.weather" => { config.providers.weather = !config.providers.weather; true }
"providers.pomodoro" => { config.providers.pomodoro = !config.providers.pomodoro; true }
"providers.frecency" => { config.providers.frecency = !config.providers.frecency; true }
_ => false,
}
}
fn set_value(&self, rest: &str, config: &mut Config) -> bool {
let Some((key, value)) = rest.split_once(':') else { return false };
match key {
"appearance.theme" => { config.appearance.theme = Some(value.to_string()); true }
"appearance.font_size" => {
if let Ok(v) = value.parse::<i32>() {
config.appearance.font_size = v;
true
} else { false }
}
"appearance.width" => {
if let Ok(v) = value.parse::<i32>() {
config.appearance.width = v;
true
} else { false }
}
"appearance.height" => {
if let Ok(v) = value.parse::<i32>() {
config.appearance.height = v;
true
} else { false }
}
"appearance.border_radius" => {
if let Ok(v) = value.parse::<i32>() {
config.appearance.border_radius = v;
true
} else { false }
}
"providers.search_engine" => { config.providers.search_engine = value.to_string(); true }
"providers.frecency_weight" => {
if let Ok(v) = value.parse::<f64>() {
config.providers.frecency_weight = v.clamp(0.0, 1.0);
true
} else { false }
}
_ => false,
}
}
fn handle_profile_action(&self, rest: &str, config: &mut Config) -> bool {
if let Some(name) = rest.strip_prefix("create:") {
config.profiles.entry(name.to_string()).or_insert_with(|| {
crate::config::ProfileConfig { modes: vec![] }
});
return true;
}
if let Some(name) = rest.strip_prefix("delete:") {
config.profiles.remove(name);
return true;
}
if let Some(rest) = rest.strip_prefix("rename:") {
if let Some((old, new)) = rest.split_once(':') {
if let Some(profile) = config.profiles.remove(old) {
config.profiles.insert(new.to_string(), profile);
return true;
}
}
return false;
}
if let Some(rest) = rest.strip_prefix("mode:") {
// format: profile_name:toggle:mode_name
let parts: Vec<&str> = rest.splitn(3, ':').collect();
if parts.len() == 3 && parts[1] == "toggle" {
let profile_name = parts[0];
let mode = parts[2];
if let Some(profile) = config.profiles.get_mut(profile_name) {
if let Some(pos) = profile.modes.iter().position(|m| m == mode) {
profile.modes.remove(pos);
} else {
profile.modes.push(mode.to_string());
}
return true;
}
}
return false;
}
false
}
}
impl DynamicProvider for ConfigProvider {
fn name(&self) -> &str {
"Config"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(PROVIDER_TYPE_ID.into())
}
fn query(&self, query: &str) -> Vec<LaunchItem> {
let config = match self.config.read() {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let path = query.trim();
self.generate_items(path, &config)
}
fn priority(&self) -> u32 {
8_000
}
}
```
- [ ] **Step 3: Implement generate_items — the query router**
Add to `ConfigProvider`:
```rust
fn generate_items(&self, path: &str, config: &Config) -> Vec<LaunchItem> {
// Top-level categories
if path.is_empty() {
return self.top_level_items();
}
let (section, rest) = match path.split_once(' ') {
Some((s, r)) => (s, r.trim()),
None => (path, ""),
};
match section {
"providers" => self.provider_items(config),
"theme" => self.theme_items(config, rest),
"engine" => self.engine_items(config),
"frecency" => self.frecency_items(config, rest),
"fontsize" => self.numeric_item("Font Size", "appearance.font_size", config.appearance.font_size, rest),
"width" => self.numeric_item("Width", "appearance.width", config.appearance.width, rest),
"height" => self.numeric_item("Height", "appearance.height", config.appearance.height, rest),
"radius" => self.numeric_item("Border Radius", "appearance.border_radius", config.appearance.border_radius, rest),
"profiles" => self.profile_items(config, rest),
"profile" => self.profile_detail_items(config, rest),
_ => self.top_level_items(),
}
}
fn top_level_items(&self) -> Vec<LaunchItem> {
vec![
self.make_item("config:providers", "Providers", "Toggle providers on/off", ""),
self.make_item("config:theme", "Theme", "Select color theme", ""),
self.make_item("config:engine", "Search Engine", "Select web search engine", ""),
self.make_item("config:frecency", "Frecency", "Frecency ranking settings", ""),
self.make_item("config:fontsize", "Font Size", "Set UI font size", ""),
self.make_item("config:width", "Width", "Set window width", ""),
self.make_item("config:height", "Height", "Set window height", ""),
self.make_item("config:radius", "Border Radius", "Set border radius", ""),
self.make_item("config:profiles", "Profiles", "Manage named mode profiles", ""),
]
}
fn make_item(&self, id: &str, name: &str, description: &str, command: &str) -> LaunchItem {
LaunchItem {
id: id.to_string(),
name: name.to_string(),
description: Some(description.to_string()),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: command.to_string(),
terminal: false,
tags: vec!["config".into(), "settings".into()],
}
}
fn toggle_item(&self, id: &str, name: &str, enabled: bool, key: &str) -> LaunchItem {
let prefix = if enabled { "" } else { "" };
LaunchItem {
id: id.to_string(),
name: format!("{} {}", prefix, name),
description: Some(format!("{} (click to toggle)", if enabled { "Enabled" } else { "Disabled" })),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:toggle:{}", key),
terminal: false,
tags: vec!["config".into()],
}
}
```
- [ ] **Step 4: Implement provider_items**
```rust
fn provider_items(&self, config: &Config) -> Vec<LaunchItem> {
vec![
self.toggle_item("config:prov:app", "Applications", config.providers.applications, "providers.applications"),
self.toggle_item("config:prov:cmd", "Commands", config.providers.commands, "providers.commands"),
self.toggle_item("config:prov:calc", "Calculator", config.providers.calculator, "providers.calculator"),
self.toggle_item("config:prov:conv", "Converter", config.providers.converter, "providers.converter"),
self.toggle_item("config:prov:sys", "System", config.providers.system, "providers.system"),
self.toggle_item("config:prov:web", "Web Search", config.providers.websearch, "providers.websearch"),
self.toggle_item("config:prov:ssh", "SSH", config.providers.ssh, "providers.ssh"),
self.toggle_item("config:prov:clip", "Clipboard", config.providers.clipboard, "providers.clipboard"),
self.toggle_item("config:prov:bm", "Bookmarks", config.providers.bookmarks, "providers.bookmarks"),
self.toggle_item("config:prov:emoji", "Emoji", config.providers.emoji, "providers.emoji"),
self.toggle_item("config:prov:scripts", "Scripts", config.providers.scripts, "providers.scripts"),
self.toggle_item("config:prov:files", "File Search", config.providers.files, "providers.files"),
self.toggle_item("config:prov:uuctl", "systemd Units", config.providers.uuctl, "providers.uuctl"),
self.toggle_item("config:prov:media", "Media", config.providers.media, "providers.media"),
self.toggle_item("config:prov:weather", "Weather", config.providers.weather, "providers.weather"),
self.toggle_item("config:prov:pomo", "Pomodoro", config.providers.pomodoro, "providers.pomodoro"),
]
}
```
- [ ] **Step 5: Implement theme_items and engine_items**
```rust
fn theme_items(&self, config: &Config, filter: &str) -> Vec<LaunchItem> {
let current = config.appearance.theme.as_deref().unwrap_or("(default)");
let themes = [
"owl", "catppuccin-mocha", "nord", "rose-pine", "dracula",
"gruvbox-dark", "tokyo-night", "solarized-dark", "one-dark", "apex-neon",
];
themes.iter()
.filter(|t| filter.is_empty() || t.contains(filter))
.map(|t| {
let mark = if *t == current { "" } else { " " };
LaunchItem {
id: format!("config:theme:{}", t),
name: format!("{}{}", mark, t),
description: Some(if *t == current { "Current theme".into() } else { "Select this theme".into() }),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:set:appearance.theme:{}", t),
terminal: false,
tags: vec!["config".into()],
}
})
.collect()
}
fn engine_items(&self, config: &Config) -> Vec<LaunchItem> {
let current = &config.providers.search_engine;
let engines = [
"duckduckgo", "google", "bing", "startpage", "brave", "ecosia",
];
engines.iter()
.map(|e| {
let mark = if *e == current.as_str() { "" } else { " " };
LaunchItem {
id: format!("config:engine:{}", e),
name: format!("{}{}", mark, e),
description: Some(if *e == current.as_str() { "Current engine".into() } else { "Select this engine".into() }),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:set:providers.search_engine:{}", e),
terminal: false,
tags: vec!["config".into()],
}
})
.collect()
}
```
- [ ] **Step 6: Implement frecency_items and numeric_item**
```rust
fn frecency_items(&self, config: &Config, rest: &str) -> Vec<LaunchItem> {
let mut items = vec![
self.toggle_item("config:frecency:toggle", "Frecency Ranking", config.providers.frecency, "providers.frecency"),
];
// If user typed a weight value, show a set action
if !rest.is_empty() {
if let Ok(v) = rest.parse::<f64>() {
let clamped = v.clamp(0.0, 1.0);
items.push(LaunchItem {
id: "config:frecency:set".into(),
name: format!("Set weight to {:.1}", clamped),
description: Some(format!("Current: {:.1}", config.providers.frecency_weight)),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:set:providers.frecency_weight:{}", clamped),
terminal: false,
tags: vec!["config".into()],
});
}
} else {
items.push(LaunchItem {
id: "config:frecency:weight".into(),
name: format!("Weight: {:.1}", config.providers.frecency_weight),
description: Some("Type a value (0.01.0) after :config frecency".into()),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: String::new(),
terminal: false,
tags: vec!["config".into()],
});
}
items
}
fn numeric_item(&self, label: &str, key: &str, current: i32, input: &str) -> Vec<LaunchItem> {
if !input.is_empty() {
if let Ok(v) = input.parse::<i32>() {
return vec![LaunchItem {
id: format!("config:set:{}", key),
name: format!("Set {} to {}", label, v),
description: Some(format!("Current: {} (restart to apply)", current)),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:set:{}:{}", key, v),
terminal: false,
tags: vec!["config".into()],
}];
}
}
vec![LaunchItem {
id: format!("config:show:{}", key),
name: format!("{}: {}", label, current),
description: Some(format!("Type a number after :config {} to change (restart to apply)", key.rsplit('.').next().unwrap_or(key))),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: String::new(),
terminal: false,
tags: vec!["config".into()],
}]
}
```
- [ ] **Step 7: Implement profile_items and profile_detail_items**
```rust
fn profile_items(&self, config: &Config, filter: &str) -> Vec<LaunchItem> {
let mut items: Vec<LaunchItem> = config.profiles.iter()
.filter(|(name, _)| filter.is_empty() || name.contains(filter))
.map(|(name, profile)| {
let modes = profile.modes.join(", ");
LaunchItem {
id: format!("config:profile:{}", name),
name: name.clone(),
description: Some(if modes.is_empty() { "(no modes)".into() } else { modes }),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: String::new(), // navigate deeper by typing :config profile <name>
terminal: false,
tags: vec!["config".into(), "profile".into()],
}
})
.collect();
// "Create" action — user types :config profile create <name>
items.push(LaunchItem {
id: "config:profile:create_hint".into(),
name: " Create New Profile".into(),
description: Some("Type: :config profile create <name>".into()),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: String::new(),
terminal: false,
tags: vec!["config".into()],
});
items
}
fn profile_detail_items(&self, config: &Config, rest: &str) -> Vec<LaunchItem> {
let (profile_name, sub) = match rest.split_once(' ') {
Some((n, s)) => (n, s.trim()),
None => (rest, ""),
};
// Handle "profile create <name>"
if profile_name == "create" && !sub.is_empty() {
return vec![LaunchItem {
id: format!("config:profile:create:{}", sub),
name: format!("Create profile '{}'", sub),
description: Some("Press Enter to create".into()),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:profile:create:{}", sub),
terminal: false,
tags: vec!["config".into()],
}];
}
let profile = match config.profiles.get(profile_name) {
Some(p) => p,
None => return vec![],
};
if sub == "modes" || sub.starts_with("modes") {
// Mode checklist
let all_modes = [
"app", "cmd", "calc", "conv", "sys", "web", "ssh", "clip",
"bm", "emoji", "scripts", "file", "uuctl", "media", "weather", "pomo",
];
return all_modes.iter()
.map(|mode| {
let enabled = profile.modes.iter().any(|m| m == mode);
let prefix = if enabled { "" } else { "" };
LaunchItem {
id: format!("config:profile:{}:mode:{}", profile_name, mode),
name: format!("{} {}", prefix, mode),
description: Some(format!("{} in profile '{}'", if enabled { "Enabled" } else { "Disabled" }, profile_name)),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:profile:mode:{}:toggle:{}", profile_name, mode),
terminal: false,
tags: vec!["config".into()],
}
})
.collect();
}
// Profile actions
vec![
LaunchItem {
id: format!("config:profile:{}:modes", profile_name),
name: "Edit Modes".into(),
description: Some(format!("Current: {}", profile.modes.join(", "))),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: String::new(), // navigate with :config profile <name> modes
terminal: false,
tags: vec!["config".into()],
},
LaunchItem {
id: format!("config:profile:{}:delete", profile_name),
name: format!("Delete profile '{}'", profile_name),
description: Some("Remove this profile".into()),
icon: Some(PROVIDER_ICON.into()),
provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()),
command: format!("CONFIG:profile:delete:{}", profile_name),
terminal: false,
tags: vec!["config".into()],
},
]
}
```
- [ ] **Step 8: Write tests**
Add at the end of `config_editor.rs`:
```rust
#[cfg(test)]
mod tests {
use super::*;
fn make_config() -> Arc<RwLock<Config>> {
Arc::new(RwLock::new(Config::default()))
}
#[test]
fn test_top_level_categories() {
let p = ConfigProvider::new(make_config());
let items = p.query("");
assert!(items.len() >= 8);
assert!(items.iter().any(|i| i.name == "Providers"));
assert!(items.iter().any(|i| i.name == "Theme"));
assert!(items.iter().any(|i| i.name == "Profiles"));
}
#[test]
fn test_provider_toggles() {
let p = ConfigProvider::new(make_config());
let items = p.query("providers");
assert!(items.len() >= 10);
assert!(items.iter().any(|i| i.name.contains("Calculator")));
}
#[test]
fn test_toggle_action() {
let config = make_config();
let p = ConfigProvider::new(Arc::clone(&config));
assert!(config.read().unwrap().providers.calculator);
assert!(p.execute_action("CONFIG:toggle:providers.calculator"));
assert!(!config.read().unwrap().providers.calculator);
assert!(p.execute_action("CONFIG:toggle:providers.calculator"));
assert!(config.read().unwrap().providers.calculator);
}
#[test]
fn test_set_theme() {
let config = make_config();
let p = ConfigProvider::new(Arc::clone(&config));
assert!(p.execute_action("CONFIG:set:appearance.theme:nord"));
assert_eq!(config.read().unwrap().appearance.theme, Some("nord".into()));
}
#[test]
fn test_set_numeric() {
let config = make_config();
let p = ConfigProvider::new(Arc::clone(&config));
assert!(p.execute_action("CONFIG:set:appearance.font_size:18"));
assert_eq!(config.read().unwrap().appearance.font_size, 18);
}
#[test]
fn test_frecency_weight_clamped() {
let config = make_config();
let p = ConfigProvider::new(Arc::clone(&config));
assert!(p.execute_action("CONFIG:set:providers.frecency_weight:2.0"));
assert_eq!(config.read().unwrap().providers.frecency_weight, 1.0);
}
#[test]
fn test_invalid_action() {
let p = ConfigProvider::new(make_config());
assert!(!p.execute_action("INVALID:something"));
assert!(!p.execute_action("CONFIG:toggle:nonexistent.key"));
}
#[test]
fn test_theme_items_show_current() {
let config = make_config();
{
config.write().unwrap().appearance.theme = Some("nord".into());
}
let p = ConfigProvider::new(config);
let items = p.query("theme");
let nord = items.iter().find(|i| i.name.contains("nord")).unwrap();
assert!(nord.name.starts_with(""));
}
#[test]
fn test_numeric_input_generates_set_action() {
let p = ConfigProvider::new(make_config());
let items = p.query("fontsize 18");
assert_eq!(items.len(), 1);
assert!(items[0].name.contains("Set Font Size to 18"));
assert_eq!(items[0].command, "CONFIG:set:appearance.font_size:18");
}
#[test]
fn test_profile_create() {
let config = make_config();
let p = ConfigProvider::new(Arc::clone(&config));
assert!(p.execute_action("CONFIG:profile:create:myprofile"));
assert!(config.read().unwrap().profiles.contains_key("myprofile"));
}
#[test]
fn test_profile_delete() {
let config = make_config();
{
config.write().unwrap().profiles.insert("test".into(), crate::config::ProfileConfig { modes: vec!["app".into()] });
}
let p = ConfigProvider::new(Arc::clone(&config));
assert!(p.execute_action("CONFIG:profile:delete:test"));
assert!(!config.read().unwrap().profiles.contains_key("test"));
}
#[test]
fn test_profile_mode_toggle() {
let config = make_config();
{
config.write().unwrap().profiles.insert("dev".into(), crate::config::ProfileConfig { modes: vec!["app".into()] });
}
let p = ConfigProvider::new(Arc::clone(&config));
// Add ssh
assert!(p.execute_action("CONFIG:profile:mode:dev:toggle:ssh"));
assert!(config.read().unwrap().profiles["dev"].modes.contains(&"ssh".into()));
// Remove app
assert!(p.execute_action("CONFIG:profile:mode:dev:toggle:app"));
assert!(!config.read().unwrap().profiles["dev"].modes.contains(&"app".into()));
}
#[test]
fn test_provider_type() {
let p = ConfigProvider::new(make_config());
assert_eq!(p.provider_type(), ProviderType::Plugin("config".into()));
}
#[test]
fn test_profile_create_query() {
let p = ConfigProvider::new(make_config());
let items = p.query("profile create myname");
assert_eq!(items.len(), 1);
assert!(items[0].name.contains("myname"));
assert_eq!(items[0].command, "CONFIG:profile:create:myname");
}
}
```
- [ ] **Step 9: Verify compilation and tests**
Run: `cargo test -p owlry-core config_editor`
Note: tests that call `execute_action` will try `config.save()` which writes to disk. The save will fail gracefully (warns) in test environment since there's no XDG config dir — the toggle/set still returns true. If tests fail due to save, add `#[allow(dead_code)]` or mock the save path. Alternatively, since `Config::save()` returns a Result and the provider logs but ignores errors, this should be fine.
Expected: All tests pass.
- [ ] **Step 10: Commit**
```bash
git add crates/owlry-core/src/providers/config_editor.rs crates/owlry-core/src/providers/mod.rs
git commit -m "feat(core): add built-in config editor provider
Interactive :config prefix for browsing and modifying settings.
Supports provider toggles, theme/engine selection, numeric input,
and profile CRUD. Uses CONFIG:* action commands that persist to
config.toml via Config::save()."
```
---
### Task 2: Wire ConfigProvider into ProviderManager
**Files:**
- Modify: `crates/owlry-core/src/providers/mod.rs`
- Modify: `crates/owlry-core/src/config/mod.rs` (if ProfileConfig is not public)
The ConfigProvider needs to be:
1. Registered as a built-in dynamic provider
2. Its `execute_action` called from `execute_plugin_action`
- [ ] **Step 1: Make Config wrap in Arc<RwLock> for shared ownership**
The ConfigProvider needs mutable access to config. Currently `new_with_config` takes `&Config`. Change the daemon startup to wrap Config in `Arc<RwLock<Config>>` and pass it to both the ConfigProvider and the server.
In `crates/owlry-core/src/providers/mod.rs`, in `new_with_config()`, after creating the config provider:
```rust
// Config editor — needs shared mutable access to config
let config_arc = std::sync::Arc::new(std::sync::RwLock::new(config.clone()));
builtin_dynamic.push(Box::new(config_editor::ConfigProvider::new(config_arc)));
info!("Registered built-in config editor provider");
```
- [ ] **Step 2: Extend execute_plugin_action for built-in providers**
In `execute_plugin_action`, after the existing native provider check, add:
```rust
// Check built-in config editor
if command.starts_with("CONFIG:") {
for provider in &self.builtin_dynamic {
if let ProviderType::Plugin(ref id) = provider.provider_type() {
if id == "config" {
// Downcast to ConfigProvider to call execute_action
// Since we can't downcast trait objects easily, add an
// execute_action method to DynamicProvider with default impl
return provider.execute_action(command);
}
}
}
}
```
For this to work, add `execute_action` to the `DynamicProvider` trait with a default no-op:
```rust
pub(crate) trait DynamicProvider: Send + Sync {
fn name(&self) -> &str;
fn provider_type(&self) -> ProviderType;
fn query(&self, query: &str) -> Vec<LaunchItem>;
fn priority(&self) -> u32;
/// Handle a plugin action command. Returns true if handled.
fn execute_action(&self, _command: &str) -> bool {
false
}
}
```
The ConfigProvider already has `execute_action` as an inherent method — just also implement it via the trait.
- [ ] **Step 3: Ensure ProfileConfig is accessible**
Check if `crate::config::ProfileConfig` is public. If not, add `pub` to its definition in `config/mod.rs`. The ConfigProvider needs to construct it for profile creation.
- [ ] **Step 4: Run tests**
Run: `cargo test -p owlry-core --lib`
Expected: All tests pass (128+ existing + new config editor tests).
- [ ] **Step 5: Commit**
```bash
git add crates/owlry-core/src/providers/mod.rs crates/owlry-core/src/config/mod.rs
git commit -m "feat(core): wire config editor into ProviderManager
Register ConfigProvider as built-in dynamic provider. Extend
execute_plugin_action to dispatch CONFIG:* commands. Add
execute_action method to DynamicProvider trait."
```
---
### Task 3: Update CLAUDE.md and README with config editor docs
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Add config editor section to README**
In the README, in the Usage section (after Keyboard Shortcuts), add:
```markdown
### Settings Editor
Type `:config` to browse and modify settings without editing files:
| Command | What it does |
|---------|-------------|
| `:config` | Show all setting categories |
| `:config providers` | Toggle providers on/off |
| `:config theme` | Select color theme |
| `:config engine` | Select web search engine |
| `:config frecency` | Toggle frecency, set weight |
| `:config fontsize 16` | Set font size (restart to apply) |
| `:config profiles` | List profiles |
| `:config profile create dev` | Create a new profile |
| `:config profile dev modes` | Edit which modes a profile includes |
Changes are saved to `config.toml` immediately. Some settings (theme, frecency) take effect on the next search. Others (font size, dimensions) require a restart.
```
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "docs: add config editor usage to README"
```
---
## Execution Notes
### Task dependency order
Task 1 is the bulk of the implementation. Task 2 wires it in. Task 3 is docs.
**Order:** 1 → 2 → 3
### What's NOT in this plan
- **Hot-apply for theme** — would need the UI to re-trigger CSS loading after a CONFIG action. Can be added later by emitting a signal from the daemon or having the UI check a flag after `execute_plugin_action` returns.
- **Profile rename via text input** — the current design supports `:config profile create <name>` but rename would need a two-step flow. Can be added later.
- **Config file watching** — if the user edits `config.toml` externally, the ConfigProvider's cached `Arc<RwLock<Config>>` becomes stale. A file watcher could reload it. Deferred.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,922 @@
# Performance Optimization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix an unsound `unsafe` block, eliminate per-keystroke clone avalanches in the search path, and apply targeted I/O and algorithmic optimizations across both the `owlry` and `owlry-plugins` repos.
**Architecture:** Nine tasks across two repos. Phase 1 removes unsound `unsafe` code. Phase 2 restructures the hot search path to score by reference and clone only the final top-N results, combined with partial-sort (`select_nth_unstable_by`) for O(n) selection. Phase 3 removes unnecessary blocking I/O and simplifies GTK list updates. Phase 4 applies minor algorithmic fixes. Phase 5 covers plugin-repo fixes (separate repo, separate branch).
**Tech Stack:** Rust 1.90+, GTK4 4.12+, owlry-core, owlry-plugins
---
## File Map
| File | Action | Responsibility |
|------|--------|----------------|
| `crates/owlry-core/src/providers/native_provider.rs` | Modify | Remove `RwLock<Vec<LaunchItem>>`, eliminate `unsafe` block |
| `crates/owlry-core/src/providers/mod.rs` | Modify | Score-by-reference in `search_with_frecency`, partial sort, clone only top N |
| `crates/owlry-core/src/data/frecency.rs` | Modify | Remove auto-save in `record_launch` |
| `crates/owlry/src/ui/main_window.rs` | Modify | Replace child-removal loop with `remove_all()` |
| `crates/owlry-core/src/providers/application.rs` | Modify | Single-pass double-space cleanup |
| `crates/owlry-core/src/filter.rs` | Modify | Merge full and partial prefix arrays into single-pass lookup |
---
## Phase 1: Safety & Correctness
### Task 1: Remove unsound `unsafe` in NativeProvider::items()
The `items()` implementation creates a raw pointer from an `RwLockReadGuard`, then drops the guard while returning a slice backed by that pointer. This is UB waiting to happen. The inner `RwLock` is unnecessary — `refresh()` takes `&mut self` (exclusive access guaranteed by the outer `Arc<RwLock<ProviderManager>>`), and `items()` takes `&self`. Replace the `RwLock<Vec<LaunchItem>>` with a plain `Vec<LaunchItem>`.
**Files:**
- Modify: `crates/owlry-core/src/providers/native_provider.rs`
- Test: `crates/owlry-core/src/providers/native_provider.rs` (existing tests)
- [ ] **Step 1: Run existing tests to establish baseline**
Run: `cargo test -p owlry-core`
Expected: All tests PASS.
- [ ] **Step 2: Replace `RwLock<Vec<LaunchItem>>` with `Vec<LaunchItem>` in the struct**
In `crates/owlry-core/src/providers/native_provider.rs`, change the struct definition:
```rust
pub struct NativeProvider {
plugin: Arc<NativePlugin>,
info: ProviderInfo,
handle: ProviderHandle,
items: Vec<LaunchItem>,
}
```
Update `new()` to initialize without RwLock:
```rust
Self {
plugin,
info,
handle,
items: Vec::new(),
}
```
- [ ] **Step 3: Remove the `unsafe` block from `items()` — return `&self.items` directly**
Replace the entire `Provider::items()` impl:
```rust
fn items(&self) -> &[LaunchItem] {
&self.items
}
```
- [ ] **Step 4: Update `refresh()` to write directly to `self.items`**
Replace the RwLock write in `refresh()`:
```rust
// Was: *self.items.write().unwrap() = items;
self.items = items;
```
- [ ] **Step 5: Update `query()` to read from `self.items` directly**
In the `query()` method, replace the RwLock read:
```rust
// Was: return self.items.read().unwrap().clone();
return self.items.clone();
```
- [ ] **Step 6: Remove `use std::sync::RwLock;` (no longer needed, `Arc` still used for plugin)**
Remove `RwLock` from the `use std::sync::{Arc, RwLock};` import:
```rust
use std::sync::Arc;
```
- [ ] **Step 7: Run tests and check**
Run: `cargo test -p owlry-core && cargo check -p owlry-core`
Expected: All tests PASS, no warnings about unused imports.
- [ ] **Step 8: Compile full workspace to verify no downstream breakage**
Run: `cargo check --workspace`
Expected: Clean compilation.
- [ ] **Step 9: Commit**
```bash
git add crates/owlry-core/src/providers/native_provider.rs
git commit -m "fix(native-provider): remove unsound unsafe in items()
Replace RwLock<Vec<LaunchItem>> with plain Vec. The inner RwLock
was unnecessary — refresh() takes &mut self (exclusive access
guaranteed by the outer Arc<RwLock<ProviderManager>>). The unsafe
block in items() dropped the RwLockReadGuard while returning a
slice backed by the raw pointer, creating a dangling reference."
```
---
## Phase 2: Hot Path Optimization
### Task 2: Eliminate clone avalanche in search_with_frecency
Currently every matching `LaunchItem` (5 Strings + Vec) is cloned during scoring, then the Vec is sorted and truncated to ~15 results — discarding 95%+ of clones. Refactor to score items by reference, partial-sort the lightweight `(&LaunchItem, i64)` tuples with `select_nth_unstable_by` (O(n) average), and clone only the top-N survivors.
**Files:**
- Modify: `crates/owlry-core/src/providers/mod.rs``search_with_frecency` method (~lines 652-875)
- Test: existing tests in `crates/owlry-core/src/providers/mod.rs`
- [ ] **Step 1: Run existing search tests to establish baseline**
Run: `cargo test -p owlry-core search`
Expected: All search-related tests PASS.
- [ ] **Step 2: Refactor `score_item` closure to return `Option<i64>` instead of `Option<(LaunchItem, i64)>`**
In `search_with_frecency`, change the closure at ~line 780 from:
```rust
let score_item = |item: &LaunchItem| -> Option<(LaunchItem, i64)> {
```
To:
```rust
let score_item = |item: &LaunchItem| -> Option<i64> {
```
And change the return from:
```rust
base_score.map(|s| {
let frecency_score = frecency.get_score_at(&item.id, now);
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
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)
})
```
To:
```rust
base_score.map(|s| {
let frecency_score = frecency.get_score_at(&item.id, now);
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
let exact_match_boost = if item.name.eq_ignore_ascii_case(query) {
match &item.provider {
ProviderType::Application => 50_000,
_ => 30_000,
}
} else {
0
};
s + frecency_boost + exact_match_boost
})
```
- [ ] **Step 3: Replace the static-item scoring loops to collect references**
Replace the scoring loops at ~lines 831-853:
```rust
// Was:
// for provider in &self.providers { ... results.push(scored); }
// for provider in &self.static_native_providers { ... results.push(scored); }
// Score static items by reference (no cloning)
let mut scored_refs: Vec<(&LaunchItem, i64)> = Vec::new();
for provider in &self.providers {
if !filter.is_active(provider.provider_type()) {
continue;
}
for item in provider.items() {
if let Some(score) = score_item(item) {
scored_refs.push((item, score));
}
}
}
for provider in &self.static_native_providers {
if !filter.is_active(provider.provider_type()) {
continue;
}
for item in provider.items() {
if let Some(score) = score_item(item) {
scored_refs.push((item, score));
}
}
}
// Partial sort: O(n) average to find top max_results, then O(k log k) to order them
if scored_refs.len() > max_results {
scored_refs.select_nth_unstable_by(max_results, |a, b| b.1.cmp(&a.1));
scored_refs.truncate(max_results);
}
scored_refs.sort_by(|a, b| b.1.cmp(&a.1));
// Clone only the survivors
results.extend(scored_refs.into_iter().map(|(item, score)| (item.clone(), score)));
```
- [ ] **Step 4: Add final merge-sort for dynamic + static results**
After extending results, add the final sort (dynamic results from earlier in the function + the newly added static results need unified ordering):
```rust
// Final sort merges dynamic results with static top-N
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
```
This replaces the existing `results.sort_by(...)` and `results.truncate(...)` lines at ~854-855 — the logic is the same, just confirming it's still present after the refactor.
- [ ] **Step 5: Optimize the empty-query path to score by reference too**
Replace the empty-query block at ~lines 739-776. Change:
```rust
let core_items = self
.providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
let native_items = self
.static_native_providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
let items: Vec<(LaunchItem, i64)> = core_items
.chain(native_items)
.filter(|item| {
if let Some(tag) = tag_filter {
item.tags.iter().any(|t| t.to_lowercase().contains(tag))
} else {
true
}
})
.map(|item| {
let frecency_score = frecency.get_score_at(&item.id, now);
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
(item, boosted)
})
.collect();
results.extend(items);
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
return results;
```
With:
```rust
let mut scored_refs: Vec<(&LaunchItem, i64)> = self
.providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter())
.chain(
self.static_native_providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter()),
)
.filter(|item| {
if let Some(tag) = tag_filter {
item.tags.iter().any(|t| t.to_lowercase().contains(tag))
} else {
true
}
})
.map(|item| {
let frecency_score = frecency.get_score_at(&item.id, now);
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
(item, boosted)
})
.collect();
if scored_refs.len() > max_results {
scored_refs.select_nth_unstable_by(max_results, |a, b| b.1.cmp(&a.1));
scored_refs.truncate(max_results);
}
scored_refs.sort_by(|a, b| b.1.cmp(&a.1));
results.extend(scored_refs.into_iter().map(|(item, score)| (item.clone(), score)));
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
return results;
```
- [ ] **Step 6: Run tests**
Run: `cargo test -p owlry-core`
Expected: All tests PASS. Search results are identical (same items, same ordering).
- [ ] **Step 7: Compile full workspace**
Run: `cargo check --workspace`
Expected: Clean compilation.
- [ ] **Step 8: Commit**
```bash
git add crates/owlry-core/src/providers/mod.rs
git commit -m "perf(search): score by reference, clone only top-N results
Refactor search_with_frecency to score static provider items by
reference (&LaunchItem, i64) instead of cloning every match.
Use select_nth_unstable_by for O(n) partial sort, then clone
only the max_results survivors. Reduces clones from O(total_matches)
to O(max_results) — typically from hundreds to ~15."
```
---
## Phase 3: I/O Optimization
### Task 3: Remove frecency auto-save on every launch
`record_launch` calls `self.save()` synchronously — serializing JSON and writing to disk on every item launch. The `Drop` impl already saves on shutdown. Mark dirty and let the caller (or shutdown) handle persistence.
**Files:**
- Modify: `crates/owlry-core/src/data/frecency.rs:98-123`
- Test: `crates/owlry-core/src/data/frecency.rs` (existing + new)
- [ ] **Step 1: Write test verifying record_launch sets dirty without saving**
Add to the `#[cfg(test)]` block in `frecency.rs`:
```rust
#[test]
fn record_launch_sets_dirty_without_saving() {
let mut store = FrecencyStore {
data: FrecencyData::default(),
path: PathBuf::from("/dev/null"),
dirty: false,
};
store.record_launch("test-item");
assert!(store.dirty, "record_launch should set dirty flag");
assert_eq!(store.data.entries["test-item"].launch_count, 1);
}
```
- [ ] **Step 2: Run test to verify it fails (current code auto-saves, clearing dirty)**
Run: `cargo test -p owlry-core record_launch_sets_dirty`
Expected: FAIL — `store.dirty` is `false` because `save()` clears it.
- [ ] **Step 3: Remove the auto-save from `record_launch`**
In `crates/owlry-core/src/data/frecency.rs`, remove lines 119-122 from `record_launch`:
```rust
// Remove this block:
// Auto-save after recording
if let Err(e) = self.save() {
warn!("Failed to save frecency data: {}", e);
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cargo test -p owlry-core record_launch_sets_dirty`
Expected: PASS.
- [ ] **Step 5: Run all frecency tests**
Run: `cargo test -p owlry-core frecency`
Expected: All PASS.
- [ ] **Step 6: Commit**
```bash
git add crates/owlry-core/src/data/frecency.rs
git commit -m "perf(frecency): remove blocking auto-save on every launch
record_launch no longer calls save() synchronously. The dirty flag
is set and the Drop impl flushes on shutdown. Removes a JSON
serialize + fs::write from the hot launch path."
```
---
### Task 4: GTK ListBox — replace child-removal loop with `remove_all()`
The current code removes children one-by-one in a `while` loop, triggering layout invalidation per removal. GTK 4.12 provides `remove_all()` which batches the operation.
**Files:**
- Modify: `crates/owlry/src/ui/main_window.rs` — two locations (~lines 705-707 and ~742-744)
- [ ] **Step 1: Replace daemon-mode child removal loop**
In the `spawn_future_local` async block (~line 705), replace:
```rust
while let Some(child) = results_list_cb.first_child() {
results_list_cb.remove(&child);
}
```
With:
```rust
results_list_cb.remove_all();
```
- [ ] **Step 2: Replace local-mode (dmenu) child removal loop**
In the synchronous (local/dmenu) branch (~line 742), replace:
```rust
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
```
With:
```rust
results_list.remove_all();
```
- [ ] **Step 3: Verify compilation**
Run: `cargo check -p owlry`
Expected: Clean compilation. `remove_all()` is available in gtk4 4.12+.
- [ ] **Step 4: Commit**
```bash
git add crates/owlry/src/ui/main_window.rs
git commit -m "perf(ui): use ListBox::remove_all() instead of per-child loop
Replaces two while-loop child removal patterns with the batched
remove_all() method available since GTK 4.12. Avoids per-removal
layout invalidation."
```
---
## Phase 4: Minor Optimizations
### Task 5: Single-pass double-space cleanup in application.rs
The `clean_desktop_exec_field` function uses a `while contains(" ") { replace(" ", " ") }` loop — O(n²) on pathological input with repeated allocations. Replace with a single-pass char iterator.
**Files:**
- Modify: `crates/owlry-core/src/providers/application.rs:60-64`
- Test: existing tests in `crates/owlry-core/src/providers/application.rs`
- [ ] **Step 1: Add test for pathological input**
Add to the existing `#[cfg(test)]` block:
```rust
#[test]
fn test_clean_desktop_exec_collapses_spaces() {
assert_eq!(clean_desktop_exec_field("app --flag arg"), "app --flag arg");
// Pathological: many consecutive spaces
let input = format!("app{}arg", " ".repeat(100));
assert_eq!(clean_desktop_exec_field(&input), "app arg");
}
```
- [ ] **Step 2: Run test to verify it passes with current implementation**
Run: `cargo test -p owlry-core clean_desktop_exec`
Expected: All PASS (current implementation works, just inefficiently).
- [ ] **Step 3: Replace the while-loop with a single-pass approach**
Replace lines 60-64 of `application.rs`:
```rust
// Was:
// let mut cleaned = result.trim().to_string();
// while cleaned.contains(" ") {
// cleaned = cleaned.replace(" ", " ");
// }
// cleaned
let trimmed = result.trim();
let mut cleaned = String::with_capacity(trimmed.len());
let mut prev_space = false;
for c in trimmed.chars() {
if c == ' ' {
if !prev_space {
cleaned.push(' ');
}
prev_space = true;
} else {
cleaned.push(c);
prev_space = false;
}
}
cleaned
```
- [ ] **Step 4: Run all application tests**
Run: `cargo test -p owlry-core clean_desktop_exec`
Expected: All PASS including the new pathological test.
- [ ] **Step 5: Commit**
```bash
git add crates/owlry-core/src/providers/application.rs
git commit -m "perf(application): single-pass double-space collapse
Replace while-contains-replace loop with a single-pass char
iterator. Eliminates O(n²) behavior and repeated allocations
on pathological desktop file Exec values."
```
---
### Task 6: Consolidate parse_query prefix matching into single pass
`parse_query` maintains four separate arrays (core_prefixes, plugin_prefixes, partial_core, partial_plugin) with duplicated prefix strings, iterating them in sequence. Merge full and partial matching into a single array and a single loop per category.
**Files:**
- Modify: `crates/owlry-core/src/filter.rs:202-406`
- Test: existing tests in `crates/owlry-core/src/filter.rs`
- [ ] **Step 1: Run existing filter tests to establish baseline**
Run: `cargo test -p owlry-core filter`
Expected: All PASS.
- [ ] **Step 2: Define a unified prefix entry struct and static arrays**
At the top of `parse_query`, replace the four separate arrays with two unified arrays:
```rust
pub fn parse_query(query: &str) -> ParsedQuery {
let trimmed = query.trim_start();
// Tag filter: ":tag:XXX query" — check first (unchanged)
if let Some(rest) = trimmed.strip_prefix(":tag:") {
if let Some(space_idx) = rest.find(' ') {
let tag = rest[..space_idx].to_lowercase();
let query_part = rest[space_idx + 1..].to_string();
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> tag={:?}, query={:?}",
query, tag, query_part
);
return ParsedQuery {
prefix: None,
tag_filter: Some(tag),
query: query_part,
};
} else {
let tag = rest.to_lowercase();
return ParsedQuery {
prefix: None,
tag_filter: Some(tag),
query: String::new(),
};
}
}
// Core prefixes — each entry is tried as ":prefix " (full) and ":prefix" (partial)
const CORE_PREFIXES: &[(&str, fn() -> ProviderType)] = &[
("app", || ProviderType::Application),
("apps", || ProviderType::Application),
("cmd", || ProviderType::Command),
("command", || ProviderType::Command),
];
// Plugin prefixes — each entry maps to a plugin type_id
const PLUGIN_PREFIXES: &[(&str, &str)] = &[
("bm", "bookmarks"),
("bookmark", "bookmarks"),
("bookmarks", "bookmarks"),
("calc", "calc"),
("calculator", "calc"),
("clip", "clipboard"),
("clipboard", "clipboard"),
("emoji", "emoji"),
("emojis", "emoji"),
("file", "filesearch"),
("files", "filesearch"),
("find", "filesearch"),
("script", "scripts"),
("scripts", "scripts"),
("ssh", "ssh"),
("sys", "system"),
("system", "system"),
("power", "system"),
("uuctl", "uuctl"),
("systemd", "uuctl"),
("web", "websearch"),
("search", "websearch"),
("config", "config"),
("settings", "config"),
("conv", "conv"),
("converter", "conv"),
];
// Single-pass: try each core prefix as both full (":prefix query") and partial (":prefix")
for (name, make_provider) in CORE_PREFIXES {
let with_space = format!(":{} ", name);
if let Some(rest) = trimmed.strip_prefix(with_space.as_str()) {
let provider = make_provider();
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
query, provider, rest
);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: rest.to_string(),
};
}
let exact = format!(":{}", name);
if trimmed == exact {
let provider = make_provider();
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> partial prefix {:?}",
query, provider
);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: String::new(),
};
}
}
// Single-pass: try each plugin prefix as both full and partial
for (name, type_id) in PLUGIN_PREFIXES {
let with_space = format!(":{} ", name);
if let Some(rest) = trimmed.strip_prefix(with_space.as_str()) {
let provider = ProviderType::Plugin(type_id.to_string());
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
query, provider, rest
);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: rest.to_string(),
};
}
let exact = format!(":{}", name);
if trimmed == exact {
let provider = ProviderType::Plugin(type_id.to_string());
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> partial prefix {:?}",
query, provider
);
return ParsedQuery {
prefix: Some(provider),
tag_filter: None,
query: String::new(),
};
}
}
// Dynamic plugin prefix fallback (unchanged)
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 == '_')
{
return ParsedQuery {
prefix: Some(ProviderType::Plugin(rest.to_string())),
tag_filter: None,
query: String::new(),
};
}
}
let result = ParsedQuery {
prefix: None,
tag_filter: None,
query: query.to_string(),
};
#[cfg(feature = "dev-logging")]
debug!(
"[Filter] parse_query({:?}) -> prefix={:?}, tag={:?}, query={:?}",
query, result.prefix, result.tag_filter, result.query
);
result
}
```
> **Note:** `CORE_PREFIXES` uses function pointers (`fn() -> ProviderType`) because `ProviderType::Application` and `ProviderType::Command` are fieldless variants that can be constructed in a const context via a trivial function. `PLUGIN_PREFIXES` stays as `(&str, &str)` because the `to_string()` allocation only happens once a prefix actually matches.
- [ ] **Step 3: Run all filter tests**
Run: `cargo test -p owlry-core filter`
Expected: All existing tests PASS (behavior unchanged).
- [ ] **Step 4: Run full check**
Run: `cargo check -p owlry-core`
Expected: Clean compilation.
- [ ] **Step 5: Commit**
```bash
git add crates/owlry-core/src/filter.rs
git commit -m "refactor(filter): consolidate parse_query prefix arrays
Merge four separate prefix arrays (core full, plugin full, core
partial, plugin partial) into two arrays with a single loop each
that checks both full and partial match. Halves the data and
eliminates the duplicate iteration."
```
---
## Phase 5: Plugin Repo Fixes
> **These tasks target `somegit.dev/Owlibou/owlry-plugins` — a separate repository.**
> Clone and branch separately. They can be done independently of Phases 1-4.
### Task 7: Filesearch — add minimum query length threshold
The filesearch plugin spawns an `fd` subprocess on every keystroke (after debounce). For short queries this is wasteful and returns too many results. Add a 3-character minimum before spawning.
**Files:**
- Modify: `owlry-plugin-filesearch/src/lib.rs``query()` method
- Test: existing or new tests
- [ ] **Step 1: Add early return in the `query` method**
At the top of the `query` function (which receives the search text), add:
```rust
fn query(&self, query: &str) -> Vec<PluginItem> {
// Don't spawn fd for very short queries — too many results, too slow
if query.len() < 3 {
return Vec::new();
}
// ... existing code ...
}
```
Find the correct method — it may be the `ProviderVTable::provider_query` path or a helper like `search_with_fd`. The guard should be placed at the earliest point before `Command::new("fd")` is invoked.
- [ ] **Step 2: Verify compilation and test**
Run: `cargo check -p owlry-plugin-filesearch`
Expected: Clean compilation.
- [ ] **Step 3: Commit**
```bash
git add owlry-plugin-filesearch/src/lib.rs
git commit -m "perf(filesearch): skip fd subprocess for queries under 3 chars
Avoids spawning a subprocess per keystroke when the user has
only typed 1-2 characters. Short queries return too many results
from fd and block the daemon's read lock."
```
---
### Task 8: Emoji plugin — avoid double clone on refresh
The emoji provider's `refresh()` returns `state.items.to_vec().into()` which clones all ~400 `PluginItem` structs. The core's `NativeProvider::refresh()` then converts each to `LaunchItem` (another set of allocations). If the plugin API supports transferring ownership instead of cloning, use that. Otherwise, this is an API-level limitation.
**Files:**
- Modify: `owlry-plugin-emoji/src/lib.rs``provider_refresh` function
- [ ] **Step 1: Check if items can be drained instead of cloned**
If `state.items` is a `Vec<PluginItem>` that gets rebuilt on each refresh anyway, drain it:
```rust
// Was: state.items.to_vec().into()
// If items are rebuilt each refresh:
std::mem::take(&mut state.items).into()
```
If `state.items` must be preserved between refreshes (because refresh is called multiple times and the items don't change), then the clone is necessary and this task is a no-op. Check the `refresh()` implementation to determine which case applies.
- [ ] **Step 2: Verify and commit if applicable**
Run: `cargo check -p owlry-plugin-emoji`
---
### Task 9: Clipboard plugin — add caching for `cliphist list`
The clipboard provider calls `cliphist list` synchronously on every refresh. If the daemon's periodic refresh timer triggers this, it blocks the RwLock. Add a simple staleness check — only re-run `cliphist list` if more than N seconds have elapsed since the last successful fetch.
**Files:**
- Modify: `owlry-plugin-clipboard/src/lib.rs`
- [ ] **Step 1: Add a `last_refresh` timestamp to the provider state**
```rust
use std::time::Instant;
struct ClipboardState {
items: Vec<PluginItem>,
last_refresh: Option<Instant>,
}
```
- [ ] **Step 2: Guard the subprocess call with a staleness check**
In the `refresh()` path:
```rust
const REFRESH_INTERVAL: Duration = Duration::from_secs(5);
if let Some(last) = state.last_refresh {
if last.elapsed() < REFRESH_INTERVAL {
return state.items.clone().into();
}
}
// ... existing cliphist list call ...
state.last_refresh = Some(Instant::now());
```
- [ ] **Step 3: Verify and commit**
Run: `cargo check -p owlry-plugin-clipboard`
```bash
git commit -m "perf(clipboard): cache cliphist results for 5 seconds
Avoids re-spawning cliphist list on every refresh cycle when the
previous results are still fresh."
```
---
## Summary
| Task | Impact | Repo | Risk |
|------|--------|------|------|
| 1. Remove unsafe in NativeProvider | CRITICAL (soundness) | owlry | Low — drops unnecessary RwLock |
| 2. Score-by-ref + partial sort | HIGH (keystroke perf) | owlry | Medium — touches hot path, verify with tests |
| 3. Remove frecency auto-save | MEDIUM (launch perf) | owlry | Low — Drop impl already saves |
| 4. ListBox remove_all() | MEDIUM (UI smoothness) | owlry | Low — direct GTK API replacement |
| 5. Single-pass space collapse | LOW (startup) | owlry | Low — purely algorithmic |
| 6. Consolidate parse_query | LOW (keystroke) | owlry | Low — existing tests cover behavior |
| 7. Filesearch min query length | HIGH (keystroke perf) | plugins | Low — early return guard |
| 8. Emoji refresh optimization | MEDIUM (startup) | plugins | Low — depends on API check |
| 9. Clipboard caching | MEDIUM (refresh perf) | plugins | Low — simple staleness check |

View File

@@ -0,0 +1,142 @@
# Codebase Hardening: owlry + owlry-plugins
**Date:** 2026-03-26
**Scope:** 15 fixes across 2 repositories, organized in 5 severity tiers
**Approach:** Severity-ordered tiers, one commit per tier, core repo first
---
## Tier 1: Critical / Soundness (owlry core)
### 1a. Replace `static mut HOST_API` with `OnceLock`
**File:** `crates/owlry-plugin-api/src/lib.rs`
**Problem:** `static mut` is unsound — concurrent reads during initialization are UB.
**Fix:** Replace with `std::sync::OnceLock<&'static HostAPI>`. `init_host_api()` calls `HOST_API.set(api)`, `host_api()` calls `HOST_API.get().copied()`. No public API changes — convenience wrappers (`notify()`, `log_info()`, etc.) keep working. No ABI impact since `HOST_API` is internal.
### 1b. Add IPC message size limit
**File:** `crates/owlry-core/src/server.rs`
**Problem:** `BufReader::lines()` reads unbounded lines. A malicious/buggy client can OOM the daemon.
**Fix:** Replace the `lines()` iterator with a manual `read_line()` loop enforcing a 1 MB max. Lines exceeding the limit get an error response and the connection is dropped. Constant: `const MAX_REQUEST_SIZE: usize = 1_048_576`.
### 1c. Handle mutex poisoning gracefully
**File:** `crates/owlry-core/src/server.rs`
**Problem:** All `lock().unwrap()` calls panic on poisoned mutex, crashing handler threads.
**Fix:** Replace with `lock().unwrap_or_else(|e| e.into_inner())`. The ProviderManager and FrecencyStore don't have invariants that require abort-on-poison.
---
## Tier 2: Security (owlry core)
### 2a. Set socket permissions after bind
**File:** `crates/owlry-core/src/server.rs`
**Problem:** Socket inherits process umask, may be readable by other local users.
**Fix:** After `UnixListener::bind()`, call `std::fs::set_permissions(socket_path, Permissions::from_mode(0o600))`. Uses `std::os::unix::fs::PermissionsExt`.
### 2b. Log signal handler failure
**File:** `crates/owlry-core/src/main.rs`
**Problem:** `ctrlc::set_handler(...).ok()` silently swallows errors. Failed handler means no socket cleanup on SIGINT.
**Fix:** Replace `.ok()` with `if let Err(e) = ... { warn!("...") }`.
### 2c. Add client read timeout
**File:** `crates/owlry-core/src/server.rs`
**Problem:** A client that connects but never sends data blocks a thread forever.
**Fix:** Set `stream.set_read_timeout(Some(Duration::from_secs(30)))` on accepted connections before entering the read loop.
---
## Tier 3: Robustness / Quality (owlry core)
### 3a. Log malformed JSON requests
**File:** `crates/owlry-core/src/server.rs`
**Problem:** JSON parse errors only sent as response to client, not visible in daemon logs.
**Fix:** Add `warn!("Malformed request from client: {}", e)` before sending the error response.
### 3b. Replace Mutex with RwLock for concurrent reads
**File:** `crates/owlry-core/src/server.rs`
**Problem:** `Mutex<ProviderManager>` blocks all concurrent queries even though they're read-only.
**Fix:** Replace both `Arc<Mutex<ProviderManager>>` and `Arc<Mutex<FrecencyStore>>` with `Arc<RwLock<...>>`.
Lock usage per request type:
| Request | ProviderManager | FrecencyStore |
|---------|----------------|---------------|
| Query | `read()` | `read()` |
| Launch | — | `write()` |
| Providers | `read()` | — |
| Refresh | `write()` | — |
| Toggle | — | — |
| Submenu | `read()` | — |
| PluginAction | `read()` | — |
Poisoning recovery: `.unwrap_or_else(|e| e.into_inner())` applies to RwLock the same way.
---
## Tier 4: Critical fixes (owlry-plugins)
### 4a. Fix `Box::leak` memory leak in converter
**File:** `owlry-plugins/crates/owlry-plugin-converter/src/units.rs`
**Problem:** `Box::leak(code.into_boxed_str())` leaks memory on every keystroke for currency queries.
**Fix:** Currency codes are already `&'static str` in `CURRENCY_ALIASES`. Change `resolve_currency_code()` return type from `Option<String>` to `Option<&'static str>` so it returns the static str directly. This eliminates the `Box::leak`. Callers in `units.rs` (`find_unit`, `convert_currency`, `convert_currency_common`) and `currency.rs` (`is_currency_alias`) must be updated to work with `&'static str` — mostly removing `.to_string()` calls or adding them at the boundary where `String` is needed (e.g., HashMap lookups that need owned keys).
### 4b. Fix bookmarks temp file race condition
**File:** `owlry-plugins/crates/owlry-plugin-bookmarks/src/lib.rs`
**Problem:** Predictable `/tmp/owlry_places_temp.sqlite` path — concurrent instances clobber, symlink attacks possible.
**Fix:** Append PID and monotonic counter to filename: `owlry_places_{pid}.sqlite`. Uses `std::process::id()`. Each profile copy gets its own name via index. Cleanup on exit remains the same.
### 4c. Fix bookmarks background refresh never updating state
**File:** `owlry-plugins/crates/owlry-plugin-bookmarks/src/lib.rs`
**Problem:** Background thread loads items and saves cache but never writes back to `self.items`. Current session keeps stale data.
**Fix:** Replace `items: Vec<PluginItem>` with `items: Arc<Mutex<Vec<PluginItem>>>`. Background thread writes to the shared vec after completing. `provider_refresh` reads from it. The `loading` AtomicBool already prevents concurrent loads.
---
## Tier 5: Quality fixes (owlry-plugins)
### 5a. SSH plugin: read terminal from config
**File:** `owlry-plugins/crates/owlry-plugin-ssh/src/lib.rs`
**Problem:** Hardcoded `kitty` as terminal fallback. Core already detects terminals.
**Fix:** Read `terminal` from `[plugins.ssh]` in `~/.config/owlry/config.toml`. Fall back to `$TERMINAL` env var, then `xdg-terminal-exec`. Same config pattern as weather/pomodoro plugins.
### 5b. WebSearch plugin: read engine from config
**File:** `owlry-plugins/crates/owlry-plugin-websearch/src/lib.rs`
**Problem:** TODO comment for config reading, never implemented. Engine is always duckduckgo.
**Fix:** Read `engine` from `[plugins.websearch]` in config.toml. Supports named engines (`google`, `duckduckgo`, etc.) or custom URL templates with `{query}`. Falls back to duckduckgo.
### 5c. Emoji plugin: build items once at init
**File:** `owlry-plugins/crates/owlry-plugin-emoji/src/lib.rs`
**Problem:** `load_emojis()` clears and rebuilds ~370 items on every `refresh()` call.
**Fix:** Call `load_emojis()` in `EmojiState::new()`. `provider_refresh` returns `self.items.clone()` without rebuilding.
### 5d. Calculator/Converter: safer shell commands
**Files:** `owlry-plugin-calculator/src/lib.rs`, `owlry-plugin-converter/src/lib.rs`
**Problem:** `sh -c 'echo -n "..."'` pattern with double-quote interpolation. Theoretically breakable by unexpected result formatting.
**Fix:** Use `printf '%s' '...' | wl-copy` with single-quote escaping (`replace('\'', "'\\''")`) — the same safe pattern already used by bookmarks and clipboard plugins.
---
## Out of scope
These were identified but deferred:
- **Hardcoded emoji list** — replacing with a crate/data file is a feature, not a fix
- **Plugin vtable-level tests** — valuable but a separate testing initiative
- **IPC protocol versioning** — protocol change, not a bug fix
- **Plugin sandbox enforcement** — large feature, not a point fix
- **Desktop Exec field sanitization** — deep rabbit hole, needs separate design
- **Config validation** — separate concern, deserves its own pass

View File

@@ -0,0 +1,161 @@
# Script Runtime Integration for owlry-core Daemon
**Date:** 2026-03-26
**Scope:** Wire up Lua/Rune script runtime loading in the daemon, fix ABI mismatch, add filesystem-watching hot-reload, update plugin documentation
**Repos:** owlry (core), owlry-plugins (docs only)
---
## Problem
The daemon (`owlry-core`) only loads native plugins from `/usr/lib/owlry/plugins/`. User script plugins in `~/.config/owlry/plugins/` are never discovered because `ProviderManager::new_with_config()` never calls the `LoadedRuntime` infrastructure that already exists in `runtime_loader.rs`. Both Lua and Rune runtimes are installed at `/usr/lib/owlry/runtimes/` and functional, but never invoked.
Additionally, the Lua runtime's `RuntimeInfo` struct has 5 fields while the core expects 2, causing a SIGSEGV on cleanup.
---
## 1. Fix Lua RuntimeInfo ABI mismatch
**File:** `owlry/crates/owlry-lua/src/lib.rs`
Shrink Lua's `RuntimeInfo` from 5 fields to 2, matching core and Rune:
```rust
// Before (5 fields — ABI mismatch with core):
pub struct RuntimeInfo {
pub id: RString,
pub name: RString,
pub version: RString,
pub description: RString,
pub api_version: u32,
}
// After (2 fields — matches core/Rune):
pub struct RuntimeInfo {
pub name: RString,
pub version: RString,
}
```
Update `runtime_info()` to return only 2 fields. Remove the `LUA_RUNTIME_API_VERSION` constant and `LuaRuntimeVTable` (use the core's `ScriptRuntimeVTable` layout — both already match). The extra metadata (`id`, `description`) was never consumed by the core.
### Vtable `init` signature change
Change the `init` function in the vtable to accept the owlry version as a second parameter:
```rust
// Before:
pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle,
// After:
pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
```
This applies to:
- `owlry-core/src/plugins/runtime_loader.rs``ScriptRuntimeVTable.init`
- `owlry-lua/src/lib.rs``LuaRuntimeVTable.init` and `runtime_init()` implementation
- `owlry-rune/src/lib.rs``RuneRuntimeVTable.init` and `runtime_init()` implementation
The core passes its version (`env!("CARGO_PKG_VERSION")` from `owlry-core`) when calling `(vtable.init)(plugins_dir, version)`. Runtimes forward it to `discover_and_load()` instead of hardcoding a version string. This keeps compatibility checks future-proof — no code changes needed on version bumps.
---
## 2. Change default entry points to `main`
**Files:**
- `owlry/crates/owlry-lua/src/manifest.rs` — change `default_entry()` from `"init.lua"` to `"main.lua"`
- `owlry/crates/owlry-rune/src/manifest.rs` — change `default_entry()` from `"init.rn"` to `"main.rn"`
Add `#[serde(alias = "entry_point")]` to the `entry` field in both manifests so existing `plugin.toml` files using `entry_point` continue to work.
---
## 3. Wire runtime loading into ProviderManager
**File:** `owlry/crates/owlry-core/src/providers/mod.rs`
In `ProviderManager::new_with_config()`, after native plugin loading:
1. Get user plugins directory from `paths::plugins_dir()`
2. Get owlry version: `env!("CARGO_PKG_VERSION")`
3. Try `LoadedRuntime::load_lua(&plugins_dir, version)` — log at `info!` if unavailable, not error
4. Try `LoadedRuntime::load_rune(&plugins_dir, version)` — same
5. Call `create_providers()` on each loaded runtime
6. Feed runtime providers into existing categorization (static/dynamic/widget)
`LoadedRuntime::load_lua`, `load_rune`, and `load_from_path` all gain an `owlry_version: &str` parameter, which is passed to `(vtable.init)(plugins_dir, owlry_version)`.
Store `LoadedRuntime` instances on `ProviderManager` in a new field `runtimes: Vec<LoadedRuntime>`. These must stay alive for the daemon's lifetime (they own the `Library` handle via `Arc`).
Remove `#![allow(dead_code)]` from `runtime_loader.rs` since it's now used.
---
## 4. Filesystem watcher for automatic hot-reload
**New file:** `owlry/crates/owlry-core/src/plugins/watcher.rs`
**Modified:** `owlry/crates/owlry-core/src/providers/mod.rs`, `Cargo.toml`
### Dependencies
Add to `owlry-core/Cargo.toml`:
```toml
notify = "7"
notify-debouncer-mini = "0.5"
```
### Watcher design
After initializing runtimes, spawn a background watcher thread:
1. Watch `~/.config/owlry/plugins/` recursively using `notify-debouncer-mini` with 500ms debounce
2. On debounced event (any file create/modify/delete):
- Acquire write lock on `ProviderManager`
- Remove all runtime-backed providers from the provider vecs
- Drop old `LoadedRuntime` instances
- Re-load runtimes from `/usr/lib/owlry/runtimes/` with fresh plugin discovery
- Add new runtime providers to provider vecs
- Refresh the new providers
- Release write lock
### Provider tracking
`ProviderManager` needs to distinguish runtime providers from native/core providers for selective removal during reload. Options:
- **Tag-based:** Runtime providers already use `ProviderType::Plugin(type_id)`. Keep a `HashSet<String>` of type_ids that came from runtimes. On reload, remove providers whose type_id is in the set.
- **Separate storage:** Store runtime providers in their own vec, separate from native providers. Query merges results from both.
**Chosen: Tag-based.** Simpler — runtime type_ids are tracked in a `runtime_type_ids: HashSet<String>` on `ProviderManager`. Reload clears the set, removes matching providers, then re-adds.
### Thread communication
The watcher thread needs access to `Arc<RwLock<ProviderManager>>`. The `Server` already holds this Arc. Pass a clone to the watcher thread at startup. The watcher acquires `write()` only during reload (~10ms), so read contention is minimal.
### Watcher lifecycle
- Started in `Server::run()` (or `Server::bind()`) before the accept loop
- Runs until the daemon exits (watcher thread is detached or joined on drop)
- Errors in the watcher (e.g., inotify limit exceeded) are logged and the watcher stops — daemon continues without hot-reload
---
## 5. Plugin development documentation
**File:** `owlry-plugins/docs/PLUGIN_DEVELOPMENT.md`
Cover:
- **Plugin directory structure** — `~/.config/owlry/plugins/<name>/plugin.toml` + `main.lua`/`main.rn`
- **Manifest reference** — all `plugin.toml` fields (`id`, `name`, `version`, `description`, `entry`/`entry_point`, `owlry_version`, `[[providers]]` section, `[permissions]` section)
- **Lua plugin guide** — `owlry.provider.register()` API with `refresh` and `query` callbacks, item table format (`id`, `name`, `command`, `description`, `icon`, `terminal`, `tags`)
- **Rune plugin guide** — `pub fn refresh()` and `pub fn query(q)` signatures, `Item::new()` builder, `use owlry::Item`
- **Hot-reload** — changes are picked up automatically, no daemon restart needed
- **Examples** — complete working examples for both Lua and Rune
---
## Out of scope
- Config-gated runtime loading (runtimes self-skip if `.so` not installed)
- Per-plugin selective reload (full runtime reload is fast enough)
- Plugin registry/installation (already exists in the CLI)
- Sandbox enforcement (separate concern, deferred from hardening spec)

View File

@@ -0,0 +1,187 @@
# Config Editor — Design Spec
## Goal
A built-in provider in owlry-core that lets users browse and modify their configuration directly from the launcher UI, without opening a text editor.
## Scope
### Editable settings (curated)
**Provider toggles** (boolean):
- applications, commands, calculator, converter, system
- websearch, ssh, clipboard, bookmarks, emoji, scripts, files
- media, weather, pomodoro
- uuctl (systemd user units)
**Appearance** (text input + selection):
- theme (selection from available themes)
- font_size (numeric input)
- width, height (numeric input)
- border_radius (numeric input)
**Search** (text input + selection):
- search_engine (selection: google, duckduckgo, bing, startpage, brave, ecosia)
- frecency (boolean toggle)
- frecency_weight (numeric input, 0.01.0)
**Profiles** (CRUD):
- List existing profiles
- Create new profile (name input + mode checklist)
- Edit profile (rename, edit modes, delete)
### Not in scope
- Weather API key / location (sensitive, better in config file)
- Pomodoro durations (niche, config file)
- Plugin disabled list (covered by provider toggles)
- use_uwsm / terminal_command (advanced, config file)
## UX Flow
### Entry point
Type `:config` or select the "Settings" item that appears for queries like "settings", "config", "preferences".
### Top-level categories
```
:config →
┌─ Providers Toggle providers on/off
├─ Appearance Theme, font size, dimensions
├─ Search Search engine, frecency
└─ Profiles Manage named mode sets
```
Each category is a submenu item. Selecting one opens its submenu.
### Provider toggles
```
Providers →
┌─ ✓ Applications [toggle]
├─ ✓ Commands [toggle]
├─ ✓ Calculator [toggle]
├─ ✓ Converter [toggle]
├─ ✓ System [toggle]
├─ ✗ Weather [toggle]
├─ ...
```
Selecting a row toggles it. The ✓/✗ prefix updates immediately. Change is written to `config.toml` and hot-applied where possible.
### Appearance settings
```
Appearance →
┌─ Theme: owl [select]
├─ Font Size: 14 [edit]
├─ Width: 850 [edit]
├─ Height: 650 [edit]
└─ Border Radius: 12 [edit]
```
**Selection fields** (theme): Selecting opens a submenu with available options. Current value is marked with ✓.
**Text/numeric fields** (font size, width, etc.): Selecting a row enters edit mode — the search bar clears and shows a placeholder like "Font Size (current: 14)". User types a new value and presses Enter. The value is validated (numeric, within reasonable range), written to config, and the submenu re-displays with the updated value.
### Search settings
```
Search →
┌─ Search Engine: duckduckgo [select]
├─ Frecency: enabled [toggle]
└─ Frecency Weight: 0.3 [edit]
```
Same patterns — selection for engine, toggle for frecency, text input for weight.
### Profile management
```
Profiles →
┌─ dev (app, cmd, ssh) [submenu]
├─ media (media, emoji) [submenu]
└─ Create New Profile [action]
```
**Select existing profile** → submenu:
```
Profile: dev →
┌─ Edit Modes [submenu → checklist]
├─ Rename [text input]
└─ Delete [confirm action]
```
**Edit Modes** → checklist (same as provider toggles but for the profile's mode list):
```
Edit Modes: dev →
┌─ ✓ app
├─ ✓ cmd
├─ ✗ calc
├─ ✗ conv
├─ ✓ ssh
├─ ...
```
Toggle to include/exclude. Changes saved on submenu exit (Escape).
**Create New Profile**:
1. Search bar becomes name input (placeholder: "Profile name...")
2. User types name, presses Enter
3. Opens mode checklist (all unchecked)
4. Toggle desired modes, press Escape to save
**Delete**: Selecting "Delete" removes the profile from config and returns to the profiles list.
## Architecture
### Provider type
Built-in static provider in owlry-core. Uses `ProviderType::Plugin("config")` with prefix `:config`.
### Provider classification
**Static** — the top-level items (Providers, Appearance, Search, Profiles) are populated at refresh time. But it also needs **submenu support** — each category opens a submenu with actions.
This means the config provider needs to handle `?SUBMENU:` queries to generate submenu items dynamically, and `!ACTION:` commands to execute changes.
### Command protocol
Actions use the existing plugin action system (`PluginAction` IPC request):
- `CONFIG:toggle:providers.calculator` — toggle a boolean
- `CONFIG:set:appearance.font_size:16` — set a value
- `CONFIG:set:providers.search_engine:google` — set a string
- `CONFIG:profile:create:dev` — create a profile
- `CONFIG:profile:delete:dev` — delete a profile
- `CONFIG:profile:rename:dev:development` — rename
- `CONFIG:profile:mode:dev:toggle:ssh` — toggle a mode in a profile
### Config persistence
All changes write to `~/.config/owlry/config.toml` via the existing `Config::save()` method.
### Hot-apply behavior
| Setting | Hot-apply | Notes |
|---------|-----------|-------|
| Provider toggles | Yes | Daemon re-reads config, enables/disables providers |
| Theme | Yes | UI reloads CSS |
| Frecency toggle/weight | Yes | Next search uses new value |
| Search engine | Yes | Next web search uses new engine |
| Font size | Restart | CSS variable, needs reload |
| Width/Height | Restart | GTK window geometry set at construction |
| Border radius | Restart | CSS variable, needs reload |
| Profiles | Yes | Config file update, available on next `--profile` launch |
Settings that require restart show a "(restart to apply)" hint in the description.
### Submenu integration
The config provider uses the existing submenu system:
- Top-level items have `SUBMENU:config:{category}` commands
- Categories return action items via `?SUBMENU:{category}`
- Actions execute via `CONFIG:*` commands through `execute_plugin_action`
This keeps the implementation within the existing provider/submenu architecture without new IPC message types.

View File

@@ -328,7 +328,7 @@ bump-meta new_version:
# === Testing ===
# Test a specific AUR package build locally
# Quick local build test (no chroot, uses host deps)
aur-test-pkg pkg:
#!/usr/bin/env bash
set -euo pipefail
@@ -337,3 +337,14 @@ aur-test-pkg pkg:
makepkg -sf
echo "Package built successfully!"
ls -lh *.pkg.tar.zst
# Build AUR packages from the local working tree in a clean chroot.
# Packages current source (incl. uncommitted changes), patches PKGBUILD,
# builds in dep order, injects local artifacts, restores PKGBUILD on exit.
#
# Examples:
# just aur-local-test owlry-core
# just aur-local-test -c owlry-core owlry-rune
# just aur-local-test --all --reset
aur-local-test *args:
scripts/aur-local-test {{args}}

329
scripts/aur-local-test Executable file
View File

@@ -0,0 +1,329 @@
#!/usr/bin/env bash
# scripts/aur-local-test
#
# Build AUR packages from the local working tree in a clean extra chroot.
#
# Packages the current working tree (including uncommitted changes) into a
# tarball, temporarily patches each PKGBUILD to use it, runs
# extra-x86_64-build, then restores the PKGBUILD on exit regardless of
# success or failure.
#
# Packages with local AUR deps (e.g. owlry-rune depends on owlry-core) are
# built in topological order and their artifacts injected automatically.
#
# Usage: scripts/aur-local-test [OPTIONS] [PKG...]
# See --help for details.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
REPO_NAME="$(basename "$REPO_ROOT")"
AUR_DIR="$REPO_ROOT/aur"
# State tracked for cleanup
TMP_TARBALL=""
declare -a PKGBUILD_BACKUPS=()
declare -a PLACED_FILES=()
# Build config
RESET_CHROOT=0
declare -a INPUT_PKGS=()
declare -a EXTRA_INJECT=() # --inject paths (external AUR deps)
# ─── Output helpers ──────────────────────────────────────────────────────────
die() { echo "error: $*" >&2; exit 1; }
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
ok() { printf '\033[1;32m ->\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m !\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31mFAIL\033[0m %s\n' "$*" >&2; }
# ─── Cleanup ─────────────────────────────────────────────────────────────────
cleanup() {
local code=$?
local f pkgbuild
# Remove tarballs placed in aur/ dirs
for f in "${PLACED_FILES[@]+"${PLACED_FILES[@]}"}"; do
[[ -f "$f" ]] && rm -f "$f"
done
# Restore patched PKGBUILDs from backups
for f in "${PKGBUILD_BACKUPS[@]+"${PKGBUILD_BACKUPS[@]}"}"; do
pkgbuild="${f%.bak}"
[[ -f "$f" ]] && mv "$f" "$pkgbuild"
done
[[ -n "$TMP_TARBALL" && -f "$TMP_TARBALL" ]] && rm -f "$TMP_TARBALL"
exit "$code"
}
trap cleanup EXIT INT TERM
# ─── Usage ───────────────────────────────────────────────────────────────────
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") [OPTIONS] [PKG...]
Build AUR packages from the local working tree in a clean chroot.
Packages current working tree (incl. uncommitted changes), patches PKGBUILD
source + checksum, runs extra-x86_64-build, then restores on exit.
Packages with local AUR deps are built in topological order and their
.pkg.tar.zst artifacts are injected into dependent builds automatically.
OPTIONS
-c, --reset Reset chroot matrix (passes -c to extra-x86_64-build).
Only applied to the first package; subsequent builds
reuse the already-fresh chroot.
-a, --all Build all packages in aur/ (respects dep order).
-I, --inject FILE Inject FILE (.pkg.tar.zst) into the chroot before
building. For AUR deps not in the official repos
(e.g. owlry-core when testing owlry-plugins).
Can be repeated.
-h, --help Show this help.
EXAMPLES
# Single package
$(basename "$0") owlry-core
# Multiple packages with chroot reset
$(basename "$0") -c owlry-core owlry-rune
# All packages in dependency order
$(basename "$0") --all --reset
# owlry-plugins: inject owlry-core from sibling repo
$(basename "$0") -I ../owlry/aur/owlry-core/owlry-core-*.pkg.tar.zst --all
EOF
exit 1
}
# ─── Argument parsing ────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
-c|--reset)
RESET_CHROOT=1
shift ;;
-a|--all)
for dir in "$AUR_DIR"/*/; do
pkg=$(basename "$dir")
[[ -f "$dir/PKGBUILD" ]] && INPUT_PKGS+=("$pkg")
done
shift ;;
-I|--inject)
[[ $# -ge 2 ]] || die "--inject requires an argument"
[[ -f "$2" ]] || die "inject file not found: $2"
EXTRA_INJECT+=("$(realpath "$2")")
shift 2 ;;
-h|--help) usage ;;
-*) die "unknown option: $1" ;;
*)
if [[ "$1" == *.pkg.tar.zst ]]; then
[[ -f "$1" ]] || die "inject file not found: $1"
EXTRA_INJECT+=("$(realpath "$1")")
else
INPUT_PKGS+=("$1")
fi
shift ;;
esac
done
[[ ${#INPUT_PKGS[@]} -eq 0 ]] && usage
# ─── Dependency resolution ───────────────────────────────────────────────────
# Return the names of local AUR packages that PKG depends on.
local_deps_of() {
local pkg="$1"
local pkgbuild="$AUR_DIR/$pkg/PKGBUILD"
[[ -f "$pkgbuild" ]] || return 0
local dep_line bare
dep_line=$(grep '^depends=' "$pkgbuild" 2>/dev/null | head -1 || true)
[[ -z "$dep_line" ]] && return 0
# Strip depends=, parens, and quotes; split on whitespace
echo "$dep_line" \
| sed "s/^depends=//; s/[()\"']/ /g" \
| tr ' ' '\n' \
| while IFS= read -r dep; do
[[ -z "$dep" ]] && continue
bare="${dep%%[><=]*}" # strip version constraints
[[ -d "$AUR_DIR/$bare" ]] && echo "$bare"
done
}
# Topological sort (DFS) — deps before dependents.
declare -A TOPO_VISITED=()
declare -a TOPO_ORDER=()
topo_visit() {
local pkg="$1"
[[ -v "TOPO_VISITED[$pkg]" ]] && return 0
TOPO_VISITED[$pkg]=1
local dep
while IFS= read -r dep; do
topo_visit "$dep"
done < <(local_deps_of "$pkg")
TOPO_ORDER+=("$pkg")
}
resolve_order() {
TOPO_VISITED=()
TOPO_ORDER=()
local pkg
for pkg in "$@"; do
topo_visit "$pkg"
done
}
# ─── Tarball creation ────────────────────────────────────────────────────────
make_tarball() {
TMP_TARBALL=$(mktemp /tmp/aur-local-XXXXXX.tar.gz)
info "Packaging ${REPO_NAME} working tree (incl. uncommitted changes)..."
tar czf "$TMP_TARBALL" \
--exclude='.git' \
--exclude='target' \
--transform "s|^\.|${REPO_NAME}|" \
-C "$REPO_ROOT" .
ok "Tarball ready: $(du -b "$TMP_TARBALL" | cut -f1 | numfmt --to=iec 2>/dev/null || wc -c < "$TMP_TARBALL") bytes"
}
# ─── PKGBUILD patching ───────────────────────────────────────────────────────
# Patch a package's PKGBUILD to use the local tarball.
# Backs up the original; cleanup() restores it on exit.
patch_pkgbuild() {
local pkg="$1"
local pkgbuild="$AUR_DIR/$pkg/PKGBUILD"
local pkgdir="$AUR_DIR/$pkg"
# Skip packages with no remote source (meta/group packages)
if ! grep -q '^source=' "$pkgbuild" || grep -qE '^source=\(\s*\)' "$pkgbuild"; then
ok "No source URL to patch — skipping tarball injection for $pkg"
return 0
fi
local pkgname pkgver filename hash
pkgname=$(grep '^pkgname=' "$pkgbuild" | cut -d= -f2- | tr -d "\"'")
pkgver=$(grep '^pkgver=' "$pkgbuild" | cut -d= -f2- | tr -d "\"'")
filename="${pkgname}-${pkgver}.tar.gz"
hash=$(b2sum "$TMP_TARBALL" | cut -d' ' -f1)
# Backup original PKGBUILD
cp "$pkgbuild" "${pkgbuild}.bak"
PKGBUILD_BACKUPS+=("${pkgbuild}.bak")
# Place local tarball where makepkg looks for it
cp "$TMP_TARBALL" "$pkgdir/$filename"
PLACED_FILES+=("$pkgdir/$filename")
# Patch source and checksum lines in-place
sed -i "s|^source=.*|source=(\"${filename}\")|" "$pkgbuild"
sed -i "s|^b2sums=.*|b2sums=('${hash}')|" "$pkgbuild"
ok "Patched PKGBUILD: source=${filename}, b2sum=${hash:0:12}…"
}
# ─── Build ───────────────────────────────────────────────────────────────────
# built_artifacts[pkg] = path to the .pkg.tar.zst produced in this run.
# Used to inject pkg artifacts into dependent builds.
declare -A BUILT_ARTIFACTS=()
find_artifact() {
local pkg="$1"
local pkgver
# pkgver is the same in patched and original PKGBUILD
pkgver=$(grep '^pkgver=' "$AUR_DIR/$pkg/PKGBUILD" | cut -d= -f2- | tr -d "\"'" \
|| grep '^pkgver=' "$AUR_DIR/$pkg/PKGBUILD.bak" | cut -d= -f2- | tr -d "\"'")
ls "$AUR_DIR/$pkg/${pkg}-${pkgver}-"*".pkg.tar.zst" 2>/dev/null \
| grep -v -- '-debug-' | sort -V | tail -1 || true
}
build_one() {
local pkg="$1"
local pkgdir="$AUR_DIR/$pkg"
info "[$pkg] Patching PKGBUILD..."
patch_pkgbuild "$pkg"
# Collect inject args: extra (external) + artifacts of local deps built earlier
local inject=()
for f in "${EXTRA_INJECT[@]+"${EXTRA_INJECT[@]}"}"; do
inject+=("-I" "$f")
done
while IFS= read -r dep; do
if [[ -v "BUILT_ARTIFACTS[$dep]" ]]; then
inject+=("-I" "${BUILT_ARTIFACTS[$dep]}")
else
warn "$pkg depends on $dep (local AUR) which was not built in this run"
warn " → Build $dep first or pass: -I path/to/${dep}-*.pkg.tar.zst"
fi
done < <(local_deps_of "$pkg")
# Build args: -c only on the first package, then cleared
local build_args=()
if [[ $RESET_CHROOT -eq 1 ]]; then
build_args+=("-c")
RESET_CHROOT=0
fi
info "[$pkg] Running extra-x86_64-build..."
(
cd "$pkgdir"
if [[ ${#inject[@]} -gt 0 ]]; then
extra-x86_64-build "${build_args[@]+"${build_args[@]}"}" -- "${inject[@]}"
else
extra-x86_64-build "${build_args[@]+"${build_args[@]}"}"
fi
)
# Record artifact for potential injection into dependents
local artifact
artifact=$(find_artifact "$pkg")
if [[ -n "$artifact" ]]; then
BUILT_ARTIFACTS[$pkg]="$artifact"
ok "[$pkg] artifact: $(basename "$artifact")"
fi
}
# ─── Main ────────────────────────────────────────────────────────────────────
# Validate all requested packages exist
for pkg in "${INPUT_PKGS[@]}"; do
[[ -d "$AUR_DIR/$pkg" && -f "$AUR_DIR/$pkg/PKGBUILD" ]] \
|| die "package not found: aur/$pkg/PKGBUILD"
done
# Sort into build order (deps before dependents)
resolve_order "${INPUT_PKGS[@]}"
# Create one tarball, reused for all packages in this run
make_tarball
declare -a FAILED=()
for pkg in "${TOPO_ORDER[@]}"; do
echo ""
if build_one "$pkg"; then
:
else
fail "[$pkg]"
FAILED+=("$pkg")
fi
done
echo ""
if [[ ${#FAILED[@]} -gt 0 ]]; then
fail "packages failed: ${FAILED[*]}"
exit 1
fi
info "All packages built successfully!"