Compare commits
30 Commits
owlry-core
...
owlry-lua-
| Author | SHA1 | Date | |
|---|---|---|---|
| 827bf383ea | |||
| b706347ec9 | |||
| 32b4b144f4 | |||
| 5615002062 | |||
| 0a3af9fa56 | |||
| c93b11e899 | |||
| bd69f8eafe | |||
| edfb079bb1 | |||
| 3de382cd73 | |||
| 82f35e5a54 | |||
| a920588df9 | |||
| c32b6c5456 | |||
| 2a5f184230 | |||
| b2f068269a | |||
| e210a604f7 | |||
| 1adec7bf47 | |||
| 7f07a93dec | |||
| 7351ba868e | |||
| 44e1430ea5 | |||
| 80312a28f7 | |||
| 37abe98c9b | |||
| d95b81bbcb | |||
| 562b38deba | |||
| 2888677e38 | |||
| 940ad58ee2 | |||
| 18775d71fc | |||
| f189f4b1ce | |||
| 422ea6d816 | |||
| 8b444eec3b | |||
| 6d0bf1c401 |
434
Cargo.lock
generated
434
Cargo.lock
generated
@@ -309,28 +309,6 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
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]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -430,17 +408,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
|
||||||
"libc",
|
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cesu8"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-expr"
|
name = "cfg-expr"
|
||||||
version = "0.20.7"
|
version = "0.20.7"
|
||||||
@@ -517,15 +487,6 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cmake"
|
|
||||||
version = "0.1.57"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codespan-reporting"
|
name = "codespan-reporting"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -542,16 +503,6 @@ version = "1.0.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
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]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.37"
|
version = "0.4.37"
|
||||||
@@ -587,16 +538,6 @@ dependencies = [
|
|||||||
"typewit",
|
"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]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -722,27 +663,12 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dunce"
|
|
||||||
version = "1.0.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
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]]
|
[[package]]
|
||||||
name = "endi"
|
name = "endi"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -896,6 +822,21 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
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]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -920,12 +861,6 @@ dependencies = [
|
|||||||
"xdg",
|
"xdg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fs_extra"
|
|
||||||
version = "1.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fsevent-sys"
|
name = "fsevent-sys"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@@ -1504,25 +1439,6 @@ dependencies = [
|
|||||||
"system-deps",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -1605,7 +1521,6 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"h2",
|
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1634,6 +1549,22 @@ dependencies = [
|
|||||||
"webpki-roots",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -1652,11 +1583,9 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
"system-configuration",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-registry",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1884,60 +1813,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.91"
|
version = "0.3.91"
|
||||||
@@ -2157,12 +2032,6 @@ dependencies = [
|
|||||||
"nom",
|
"nom",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mime"
|
|
||||||
version = "0.3.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -2259,6 +2128,23 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"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]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.31.2"
|
version = "0.31.2"
|
||||||
@@ -2503,12 +2389,50 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
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]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
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]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2536,7 +2460,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "owlry"
|
name = "owlry"
|
||||||
version = "1.0.5"
|
version = "1.0.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -2557,7 +2481,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "owlry-core"
|
name = "owlry-core"
|
||||||
version = "1.2.0"
|
version = "1.3.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
@@ -2584,7 +2508,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "owlry-lua"
|
name = "owlry-lua"
|
||||||
version = "1.1.0"
|
version = "1.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"abi_stable",
|
"abi_stable",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -2610,7 +2534,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "owlry-rune"
|
name = "owlry-rune"
|
||||||
version = "1.1.0"
|
version = "1.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
@@ -2863,7 +2787,6 @@ version = "0.11.14"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
|
||||||
"bytes",
|
"bytes",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"lru-slab",
|
"lru-slab",
|
||||||
@@ -3056,31 +2979,26 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-tls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
|
||||||
"rustls",
|
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-platform-verifier",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-native-tls",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -3246,7 +3164,6 @@ version = "0.23.37"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -3255,18 +3172,6 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.0"
|
version = "1.14.0"
|
||||||
@@ -3277,40 +3182,12 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.10"
|
version = "0.103.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"untrusted",
|
"untrusted",
|
||||||
@@ -3365,7 +3242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
@@ -3606,27 +3483,6 @@ version = "0.18.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "00c99c9cda412afe293a6b962af651b4594161ba88c1affe7ef66459ea040a06"
|
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]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "7.0.7"
|
version = "7.0.7"
|
||||||
@@ -3794,6 +3650,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@@ -4156,6 +4022,12 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -4324,15 +4196,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -4485,17 +4348,6 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -4532,15 +4384,6 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.48.0"
|
version = "0.48.0"
|
||||||
@@ -4586,21 +4429,6 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
@@ -4667,12 +4495,6 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
@@ -4691,12 +4513,6 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_aarch64_msvc"
|
|
||||||
version = "0.42.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
@@ -4715,12 +4531,6 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_gnu"
|
|
||||||
version = "0.42.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
@@ -4751,12 +4561,6 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_msvc"
|
|
||||||
version = "0.42.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
@@ -4775,12 +4579,6 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
@@ -4799,12 +4597,6 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
@@ -4823,12 +4615,6 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
|
|||||||
99
README.md
99
README.md
@@ -11,17 +11,18 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Client/daemon architecture** — Instant window appearance, providers stay loaded in memory
|
- **Client/daemon architecture** — Instant window appearance, providers stay loaded in memory
|
||||||
- **Modular plugin architecture** — Install only what you need
|
- **Built-in providers** — Calculator, unit/currency converter, and system actions out of the box
|
||||||
- **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags
|
- **Built-in settings editor** — Configure everything from within the launcher (`:config`)
|
||||||
- **Built-in calculator, converter, and system actions** — Works out of the box
|
- **11 optional plugins** — Clipboard, emoji, weather, media, bookmarks, and more
|
||||||
- **11 optional plugins** — Clipboard, emoji, weather, media, and more
|
|
||||||
- **Widget providers** — Weather, media controls, and pomodoro timer at the top of results
|
- **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
|
- **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
|
- **Frecency ranking** — Frequently/recently used items rank higher
|
||||||
- **Toggle behavior** — Bind one key to open/close the launcher
|
- **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
|
- **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
|
- **Extensible** — Create custom plugins in Lua or Rune
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -29,13 +30,13 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
|
|||||||
### Arch Linux (AUR)
|
### Arch Linux (AUR)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Core (includes calculator, converter, system actions)
|
# Core (includes calculator, converter, system actions, settings editor)
|
||||||
yay -S owlry
|
yay -S owlry
|
||||||
|
|
||||||
# Add individual plugins as needed
|
# Add individual plugins as needed
|
||||||
yay -S owlry-plugin-bookmarks owlry-plugin-weather owlry-plugin-clipboard
|
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-lua # Lua 5.4 runtime
|
||||||
yay -S owlry-rune # Rune runtime
|
yay -S owlry-rune # Rune runtime
|
||||||
```
|
```
|
||||||
@@ -47,7 +48,7 @@ yay -S owlry-rune # Rune runtime
|
|||||||
| Package | Description |
|
| Package | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `owlry` | GTK4 UI client |
|
| `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-lua` | Lua 5.4 script runtime for user plugins |
|
||||||
| `owlry-rune` | Rune 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-weather` | Weather widget |
|
||||||
| `owlry-plugin-websearch` | Web search (`? query`) |
|
| `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
|
### Build from Source
|
||||||
|
|
||||||
@@ -102,7 +103,7 @@ cargo build --release --workspace
|
|||||||
```bash
|
```bash
|
||||||
git clone https://somegit.dev/Owlibou/owlry-plugins.git
|
git clone https://somegit.dev/Owlibou/owlry-plugins.git
|
||||||
cd owlry-plugins
|
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:**
|
**Install locally:**
|
||||||
@@ -110,11 +111,11 @@ cargo build --release -p owlry-plugin-calculator # or any plugin
|
|||||||
just install-local
|
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
|
## 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
|
### Starting the Daemon
|
||||||
|
|
||||||
@@ -126,25 +127,25 @@ Add to your compositor config:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Hyprland (~/.config/hypr/hyprland.conf)
|
# Hyprland (~/.config/hypr/hyprland.conf)
|
||||||
exec-once = owlry-core
|
exec-once = owlryd
|
||||||
|
|
||||||
# Sway (~/.config/sway/config)
|
# Sway (~/.config/sway/config)
|
||||||
exec owlry-core
|
exec owlryd
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Systemd user service**
|
**2. Systemd user service**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl --user enable --now owlry-core.service
|
systemctl --user enable --now owlryd.service
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. Socket activation (auto-start on first use)**
|
**3. Socket activation (auto-start on first use)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl --user enable owlry-core.socket
|
systemctl --user enable owlryd.socket
|
||||||
```
|
```
|
||||||
|
|
||||||
The daemon starts automatically when the UI client first connects. No manual startup needed.
|
The daemon starts automatically when the UI client first connects.
|
||||||
|
|
||||||
### Launching the UI
|
### Launching the UI
|
||||||
|
|
||||||
@@ -158,7 +159,7 @@ bind = SUPER, Space, exec, owlry
|
|||||||
bindsym $mod+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.
|
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 # Launch with all providers
|
||||||
owlry -m app # Applications only
|
owlry -m app # Applications only
|
||||||
owlry -m cmd # PATH commands 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 --profile dev # Use a named profile from config
|
||||||
owlry --help # Show all options with examples
|
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
|
bind = SUPER, M, exec, owlry --profile media
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Profiles can also be managed from the launcher itself — see [Settings Editor](#settings-editor).
|
||||||
|
|
||||||
### dmenu Mode
|
### 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.
|
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
|
```bash
|
||||||
# Screenshot menu (execute selected command)
|
# Screenshot menu
|
||||||
printf '%s\n' \
|
printf '%s\n' \
|
||||||
"grimblast --notify copy screen" \
|
"grimblast --notify copy screen" \
|
||||||
"grimblast --notify copy area" \
|
"grimblast --notify copy area" \
|
||||||
@@ -229,9 +232,6 @@ find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code
|
|||||||
|
|
||||||
# Package manager search
|
# Package manager search
|
||||||
pacman -Ssq | owlry -m dmenu -p "install" | xargs sudo pacman -S
|
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.
|
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) |
|
| `Shift+Tab` | Cycle filter tabs (reverse) |
|
||||||
| `Ctrl+1..9` | Toggle tab by position |
|
| `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
|
### Search Prefixes
|
||||||
|
|
||||||
| Prefix | Provider | Example |
|
| Prefix | Provider | Example |
|
||||||
@@ -263,6 +281,7 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
|
|||||||
| `:calc` | Calculator | `:calc sqrt(16)` |
|
| `:calc` | Calculator | `:calc sqrt(16)` |
|
||||||
| `:web` | Web search | `:web rust docs` |
|
| `:web` | Web search | `:web rust docs` |
|
||||||
| `:uuctl` | systemd | `:uuctl docker` |
|
| `:uuctl` | systemd | `:uuctl docker` |
|
||||||
|
| `:config` | Settings | `:config theme` |
|
||||||
| `:tag:X` | Filter by tag | `:tag:development` |
|
| `:tag:X` | Filter by tag | `:tag:development` |
|
||||||
|
|
||||||
### Trigger Prefixes
|
### Trigger Prefixes
|
||||||
@@ -271,6 +290,7 @@ The `-p` / `--prompt` flag sets a custom label for the search input.
|
|||||||
|---------|----------|---------|
|
|---------|----------|---------|
|
||||||
| `=` | Calculator | `= 5+3` |
|
| `=` | Calculator | `= 5+3` |
|
||||||
| `calc ` | Calculator | `calc sqrt(16)` |
|
| `calc ` | Calculator | `calc sqrt(16)` |
|
||||||
|
| `>` | Converter | `> 20 km to mi` |
|
||||||
| `?` | Web search | `? rust programming` |
|
| `?` | Web search | `? rust programming` |
|
||||||
| `web ` | Web search | `web linux tips` |
|
| `web ` | Web search | `web linux tips` |
|
||||||
| `/` | File search | `/ .bashrc` |
|
| `/` | File search | `/ .bashrc` |
|
||||||
@@ -290,6 +310,7 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free
|
|||||||
| `~/.local/share/owlry/frecency.json` | Usage history |
|
| `~/.local/share/owlry/frecency.json` | Usage history |
|
||||||
|
|
||||||
System locations:
|
System locations:
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `/usr/lib/owlry/plugins/*.so` | Installed native plugins |
|
| `/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
|
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
|
### Example Configuration
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
@@ -327,6 +350,9 @@ disabled = [] # Plugin IDs to disable, e.g., ["emoji", "pomodoro"]
|
|||||||
[providers]
|
[providers]
|
||||||
applications = true # .desktop files
|
applications = true # .desktop files
|
||||||
commands = true # PATH executables
|
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 = true # Boost frequently used items
|
||||||
frecency_weight = 0.3 # 0.0-1.0
|
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
|
## 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)
|
- `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages)
|
||||||
- `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`)
|
- `~/.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"]
|
disabled = ["emoji", "pomodoro"]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or toggle providers interactively: type `:config providers` in the launcher.
|
||||||
|
|
||||||
### Plugin Management CLI
|
### Plugin Management CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -414,12 +442,15 @@ See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for:
|
|||||||
| `tokyo-night` | Tokyo city lights |
|
| `tokyo-night` | Tokyo city lights |
|
||||||
| `solarized-dark` | Precision colors |
|
| `solarized-dark` | Precision colors |
|
||||||
| `one-dark` | Atom's One Dark |
|
| `one-dark` | Atom's One Dark |
|
||||||
|
| `apex-neon` | Neon cyberpunk |
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[appearance]
|
[appearance]
|
||||||
theme = "catppuccin-mocha"
|
theme = "catppuccin-mocha"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or select interactively: type `:config theme` in the launcher.
|
||||||
|
|
||||||
### Custom Theme
|
### Custom Theme
|
||||||
|
|
||||||
Create `~/.config/owlry/themes/mytheme.css`:
|
Create `~/.config/owlry/themes/mytheme.css`:
|
||||||
@@ -447,18 +478,24 @@ Create `~/.config/owlry/themes/mytheme.css`:
|
|||||||
| `--owlry-text-secondary` | Muted text |
|
| `--owlry-text-secondary` | Muted text |
|
||||||
| `--owlry-accent` | Accent color |
|
| `--owlry-accent` | Accent color |
|
||||||
| `--owlry-accent-bright` | Bright accent |
|
| `--owlry-accent-bright` | Bright accent |
|
||||||
|
| `--owlry-shadow` | Window shadow (default: none) |
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Owlry uses a client/daemon split:
|
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
|
├── Loads config + plugins ├── Connects to daemon via Unix socket
|
||||||
├── Applications provider ├── Renders results in GTK4 window
|
├── Built-in providers ├── Renders results in GTK4 window
|
||||||
├── Commands provider ├── Handles keyboard input
|
│ ├── Applications (.desktop) ├── Handles keyboard input
|
||||||
├── Plugin loader ├── Toggle: second launch closes window
|
│ ├── Commands (PATH) ├── Toggle: second launch closes window
|
||||||
│ ├── /usr/lib/owlry/plugins/*.so └── dmenu mode (self-contained, no daemon)
|
│ ├── 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/
|
│ ├── /usr/lib/owlry/runtimes/
|
||||||
│ └── ~/.config/owlry/plugins/
|
│ └── ~/.config/owlry/plugins/
|
||||||
├── Frecency tracking
|
├── Frecency tracking
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
pkgbase = owlry-core
|
pkgbase = owlry-core
|
||||||
pkgdesc = Core daemon for the Owlry application launcher — manages plugins, providers, and search
|
pkgdesc = Core daemon for the Owlry application launcher — manages plugins, providers, and search
|
||||||
pkgver = 1.1.3
|
pkgver = 1.3.2
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://somegit.dev/Owlibou/owlry
|
url = https://somegit.dev/Owlibou/owlry
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
license = GPL-3.0-or-later
|
license = GPL-3.0-or-later
|
||||||
makedepends = cargo
|
makedepends = cargo
|
||||||
depends = gcc-libs
|
depends = gcc-libs
|
||||||
source = owlry-core-1.1.3.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v1.1.3.tar.gz
|
depends = openssl
|
||||||
b2sums = 3f7b9cde30a06d96f8c1fda1be72514ac5b0e835402c1a287bfd2d8ea92284874b5fcccfcd08d249eb7f28a3b5be6a3b77c495e610fe9742a6c7b3d5084c9894
|
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
|
pkgname = owlry-core
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||||
pkgname=owlry-core
|
pkgname=owlry-core
|
||||||
pkgver=1.1.3
|
pkgver=1.3.2
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc='Core daemon for the Owlry application launcher — manages plugins, providers, and search'
|
pkgdesc='Core daemon for the Owlry application launcher — manages plugins, providers, and search'
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url='https://somegit.dev/Owlibou/owlry'
|
url='https://somegit.dev/Owlibou/owlry'
|
||||||
license=('GPL-3.0-or-later')
|
license=('GPL-3.0-or-later')
|
||||||
depends=('gcc-libs')
|
depends=('gcc-libs' 'openssl')
|
||||||
makedepends=('cargo')
|
makedepends=('cargo')
|
||||||
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v$pkgver.tar.gz")
|
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v$pkgver.tar.gz")
|
||||||
b2sums=('3f7b9cde30a06d96f8c1fda1be72514ac5b0e835402c1a287bfd2d8ea92284874b5fcccfcd08d249eb7f28a3b5be6a3b77c495e610fe9742a6c7b3d5084c9894')
|
b2sums=('36a1e31cadcfdbe70c0a10c13eddbcea7ae21b7dcfb0aa10a75f44a82a377d6598c4237228457c13260ca4b4b88f12d416541ad7698cf28076124b1a4d3dbbc6')
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
cd "owlry"
|
cd "owlry"
|
||||||
@@ -33,9 +33,9 @@ check() {
|
|||||||
|
|
||||||
package() {
|
package() {
|
||||||
cd "owlry"
|
cd "owlry"
|
||||||
install -Dm755 "target/release/owlry-core" "$pkgdir/usr/bin/owlry-core"
|
install -Dm755 "target/release/owlryd" "$pkgdir/usr/bin/owlryd"
|
||||||
install -Dm644 "systemd/owlry-core.service" "$pkgdir/usr/lib/systemd/user/owlry-core.service"
|
install -Dm644 "systemd/owlryd.service" "$pkgdir/usr/lib/systemd/user/owlryd.service"
|
||||||
install -Dm644 "systemd/owlry-core.socket" "$pkgdir/usr/lib/systemd/user/owlry-core.socket"
|
install -Dm644 "systemd/owlryd.socket" "$pkgdir/usr/lib/systemd/user/owlryd.socket"
|
||||||
install -dm755 "$pkgdir/usr/lib/owlry/plugins"
|
install -dm755 "$pkgdir/usr/lib/owlry/plugins"
|
||||||
install -dm755 "$pkgdir/usr/lib/owlry/runtimes"
|
install -dm755 "$pkgdir/usr/lib/owlry/runtimes"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
pkgbase = owlry-lua
|
pkgbase = owlry-lua
|
||||||
pkgdesc = Lua scripting runtime for Owlry — enables user-created Lua plugins
|
pkgdesc = Lua scripting runtime for Owlry — enables user-created Lua plugins
|
||||||
pkgver = 1.1.0
|
pkgver = 1.1.1
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://somegit.dev/Owlibou/owlry
|
url = https://somegit.dev/Owlibou/owlry
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
license = GPL-3.0-or-later
|
license = GPL-3.0-or-later
|
||||||
makedepends = cargo
|
makedepends = cargo
|
||||||
depends = owlry-core
|
depends = owlry-core
|
||||||
source = owlry-lua-1.1.0.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v1.1.0.tar.gz
|
source = owlry-lua-1.1.1.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v1.1.1.tar.gz
|
||||||
b2sums = d4b200446a31301b1240fd8eede6e10764d7bbc551f2e5549bfdbdcc0fa4a717677c3c2c69778d2dfa336711ac5b74d4987e46082ea589fed961c9d2ff95af76
|
b2sums = a0e1fa032db8dda8e6bc24457f3c04948129d3f14c1d3e61b8e080340b24f560d43294beb133ad4b1c6eb7942d401108ea91c367b074eaeeefa284e9b2a9dbc8
|
||||||
|
|
||||||
pkgname = owlry-lua
|
pkgname = owlry-lua
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||||
pkgname=owlry-lua
|
pkgname=owlry-lua
|
||||||
pkgver=1.1.0
|
pkgver=1.1.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Lua scripting runtime for Owlry — enables user-created Lua plugins"
|
pkgdesc="Lua scripting runtime for Owlry — enables user-created Lua plugins"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://somegit.dev/Owlibou/owlry"
|
url="https://somegit.dev/Owlibou/owlry"
|
||||||
license=('GPL-3.0-or-later')
|
license=('GPL-3.0-or-later')
|
||||||
depends=('owlry-core')
|
depends=('owlry-core' 'openssl')
|
||||||
makedepends=('cargo')
|
makedepends=('cargo')
|
||||||
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v$pkgver.tar.gz")
|
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v$pkgver.tar.gz")
|
||||||
b2sums=('d4b200446a31301b1240fd8eede6e10764d7bbc551f2e5549bfdbdcc0fa4a717677c3c2c69778d2dfa336711ac5b74d4987e46082ea589fed961c9d2ff95af76')
|
b2sums=('a0e1fa032db8dda8e6bc24457f3c04948129d3f14c1d3e61b8e080340b24f560d43294beb133ad4b1c6eb7942d401108ea91c367b074eaeeefa284e9b2a9dbc8')
|
||||||
|
|
||||||
_cratename=owlry-lua
|
_cratename=owlry-lua
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ check() {
|
|||||||
cd "owlry"
|
cd "owlry"
|
||||||
export RUSTUP_TOOLCHAIN=stable
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
export CARGO_TARGET_DIR=target
|
export CARGO_TARGET_DIR=target
|
||||||
cargo test -p $_cratename --frozen --release
|
cargo test -p $_cratename --frozen --lib
|
||||||
}
|
}
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
|
|||||||
1
aur/owlry-meta-essentials
Submodule
1
aur/owlry-meta-essentials
Submodule
Submodule aur/owlry-meta-essentials added at 4a09cfb73c
1
aur/owlry-meta-full
Submodule
1
aur/owlry-meta-full
Submodule
Submodule aur/owlry-meta-full added at 8f85087731
1
aur/owlry-meta-tools
Submodule
1
aur/owlry-meta-tools
Submodule
Submodule aur/owlry-meta-tools added at 28c78b7953
1
aur/owlry-meta-widgets
Submodule
1
aur/owlry-meta-widgets
Submodule
Submodule aur/owlry-meta-widgets added at aa4c2cd217
@@ -1,13 +1,13 @@
|
|||||||
pkgbase = owlry-rune
|
pkgbase = owlry-rune
|
||||||
pkgdesc = Rune scripting runtime for Owlry — enables user-created Rune plugins
|
pkgdesc = Rune scripting runtime for Owlry — enables user-created Rune plugins
|
||||||
pkgver = 1.1.0
|
pkgver = 1.1.1
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://somegit.dev/Owlibou/owlry
|
url = https://somegit.dev/Owlibou/owlry
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
license = GPL-3.0-or-later
|
license = GPL-3.0-or-later
|
||||||
makedepends = cargo
|
makedepends = cargo
|
||||||
depends = owlry-core
|
depends = owlry-core
|
||||||
source = owlry-rune-1.1.0.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v1.1.0.tar.gz
|
source = owlry-rune-1.1.1.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v1.1.1.tar.gz
|
||||||
b2sums = d4b200446a31301b1240fd8eede6e10764d7bbc551f2e5549bfdbdcc0fa4a717677c3c2c69778d2dfa336711ac5b74d4987e46082ea589fed961c9d2ff95af76
|
b2sums = a0e1fa032db8dda8e6bc24457f3c04948129d3f14c1d3e61b8e080340b24f560d43294beb133ad4b1c6eb7942d401108ea91c367b074eaeeefa284e9b2a9dbc8
|
||||||
|
|
||||||
pkgname = owlry-rune
|
pkgname = owlry-rune
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||||
pkgname=owlry-rune
|
pkgname=owlry-rune
|
||||||
pkgver=1.1.0
|
pkgver=1.1.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Rune scripting runtime for Owlry — enables user-created Rune plugins"
|
pkgdesc="Rune scripting runtime for Owlry — enables user-created Rune plugins"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://somegit.dev/Owlibou/owlry"
|
url="https://somegit.dev/Owlibou/owlry"
|
||||||
license=('GPL-3.0-or-later')
|
license=('GPL-3.0-or-later')
|
||||||
depends=('owlry-core')
|
depends=('owlry-core' 'openssl')
|
||||||
makedepends=('cargo')
|
makedepends=('cargo')
|
||||||
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v$pkgver.tar.gz")
|
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v$pkgver.tar.gz")
|
||||||
b2sums=('d4b200446a31301b1240fd8eede6e10764d7bbc551f2e5549bfdbdcc0fa4a717677c3c2c69778d2dfa336711ac5b74d4987e46082ea589fed961c9d2ff95af76')
|
b2sums=('a0e1fa032db8dda8e6bc24457f3c04948129d3f14c1d3e61b8e080340b24f560d43294beb133ad4b1c6eb7942d401108ea91c367b074eaeeefa284e9b2a9dbc8')
|
||||||
|
|
||||||
_cratename=owlry-rune
|
_cratename=owlry-rune
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = owlry
|
pkgbase = owlry
|
||||||
pkgdesc = Lightweight Wayland application launcher with plugin support
|
pkgdesc = Lightweight Wayland application launcher with plugin support
|
||||||
pkgver = 1.0.5
|
pkgver = 1.0.6
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://somegit.dev/Owlibou/owlry
|
url = https://somegit.dev/Owlibou/owlry
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -28,7 +28,7 @@ pkgbase = owlry
|
|||||||
optdepends = owlry-plugin-pomodoro: pomodoro timer widget
|
optdepends = owlry-plugin-pomodoro: pomodoro timer widget
|
||||||
optdepends = owlry-lua: Lua runtime for user plugins
|
optdepends = owlry-lua: Lua runtime for user plugins
|
||||||
optdepends = owlry-rune: Rune 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
|
source = owlry-1.0.6.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v1.0.6.tar.gz
|
||||||
b2sums = 3f7b9cde30a06d96f8c1fda1be72514ac5b0e835402c1a287bfd2d8ea92284874b5fcccfcd08d249eb7f28a3b5be6a3b77c495e610fe9742a6c7b3d5084c9894
|
b2sums = 8967562bda33820b282350eaad17e8194699926b721eabe978fb0b70af2a75e399866c6bfa7abb449141701bad618df56079c7e81358708b1852b1070b0b7c05
|
||||||
|
|
||||||
pkgname = owlry
|
pkgname = owlry
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||||
pkgname=owlry
|
pkgname=owlry
|
||||||
pkgver=1.0.5
|
pkgver=1.0.6
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Lightweight Wayland application launcher with plugin support"
|
pkgdesc="Lightweight Wayland application launcher with plugin support"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -29,7 +29,7 @@ optdepends=(
|
|||||||
'owlry-rune: Rune runtime for user plugins'
|
'owlry-rune: Rune runtime for user plugins'
|
||||||
)
|
)
|
||||||
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v$pkgver.tar.gz")
|
source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v$pkgver.tar.gz")
|
||||||
b2sums=('3f7b9cde30a06d96f8c1fda1be72514ac5b0e835402c1a287bfd2d8ea92284874b5fcccfcd08d249eb7f28a3b5be6a3b77c495e610fe9742a6c7b3d5084c9894')
|
b2sums=('8967562bda33820b282350eaad17e8194699926b721eabe978fb0b70af2a75e399866c6bfa7abb449141701bad618df56079c7e81358708b1852b1070b0b7c05')
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
cd "owlry"
|
cd "owlry"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "owlry-core"
|
name = "owlry-core"
|
||||||
version = "1.2.0"
|
version = "1.3.2"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
@@ -12,7 +12,7 @@ name = "owlry_core"
|
|||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "owlry-core"
|
name = "owlryd"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -50,7 +50,7 @@ notify-rust = "4"
|
|||||||
|
|
||||||
# Built-in providers
|
# Built-in providers
|
||||||
meval = "0.2"
|
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
|
# Optional: embedded Lua runtime
|
||||||
mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true }
|
mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true }
|
||||||
|
|||||||
@@ -115,11 +115,6 @@ impl FrecencyStore {
|
|||||||
"Recorded launch for '{}': count={}, last={}",
|
"Recorded launch for '{}': count={}, last={}",
|
||||||
item_id, entry.launch_count, entry.last_launch
|
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
|
/// Calculate frecency score for an item
|
||||||
@@ -255,4 +250,18 @@ mod tests {
|
|||||||
assert!(score_many > score_few);
|
assert!(score_many > score_few);
|
||||||
assert!((score_many / score_few - 10.0).abs() < 0.1); // Should be ~10x
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)] = &[
|
let core_prefixes: &[(&str, ProviderType)] = &[
|
||||||
(":app ", ProviderType::Application),
|
("app", ProviderType::Application),
|
||||||
(":apps ", ProviderType::Application),
|
("apps", ProviderType::Application),
|
||||||
(":cmd ", ProviderType::Command),
|
("cmd", ProviderType::Command),
|
||||||
(":command ", 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)] = &[
|
let plugin_prefixes: &[(&str, &str)] = &[
|
||||||
(":bm ", "bookmarks"),
|
("bm", "bookmarks"),
|
||||||
(":bookmark ", "bookmarks"),
|
("bookmark", "bookmarks"),
|
||||||
(":bookmarks ", "bookmarks"),
|
("bookmarks", "bookmarks"),
|
||||||
(":calc ", "calc"),
|
("calc", "calc"),
|
||||||
(":calculator ", "calc"),
|
("calculator", "calc"),
|
||||||
(":clip ", "clipboard"),
|
("clip", "clipboard"),
|
||||||
(":clipboard ", "clipboard"),
|
("clipboard", "clipboard"),
|
||||||
(":emoji ", "emoji"),
|
("emoji", "emoji"),
|
||||||
(":emojis ", "emoji"),
|
("emojis", "emoji"),
|
||||||
(":file ", "filesearch"),
|
("file", "filesearch"),
|
||||||
(":files ", "filesearch"),
|
("files", "filesearch"),
|
||||||
(":find ", "filesearch"),
|
("find", "filesearch"),
|
||||||
(":script ", "scripts"),
|
("script", "scripts"),
|
||||||
(":scripts ", "scripts"),
|
("scripts", "scripts"),
|
||||||
(":ssh ", "ssh"),
|
("ssh", "ssh"),
|
||||||
(":sys ", "system"),
|
("sys", "system"),
|
||||||
(":system ", "system"),
|
("system", "system"),
|
||||||
(":power ", "system"),
|
("power", "system"),
|
||||||
(":uuctl ", "uuctl"),
|
("uuctl", "uuctl"),
|
||||||
(":systemd ", "uuctl"),
|
("systemd", "uuctl"),
|
||||||
(":web ", "websearch"),
|
("web", "websearch"),
|
||||||
(":search ", "websearch"),
|
("search", "websearch"),
|
||||||
|
("config", "config"),
|
||||||
|
("settings", "config"),
|
||||||
|
("conv", "conv"),
|
||||||
|
("converter", "conv"),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Check core prefixes
|
// Single-pass: try each core prefix as both full (":name query") and partial (":name")
|
||||||
for (prefix_str, provider) in core_prefixes {
|
for (name, provider) in core_prefixes {
|
||||||
if let Some(rest) = trimmed.strip_prefix(prefix_str) {
|
let with_space = format!(":{} ", name);
|
||||||
|
if let Some(rest) = trimmed.strip_prefix(with_space.as_str()) {
|
||||||
#[cfg(feature = "dev-logging")]
|
#[cfg(feature = "dev-logging")]
|
||||||
debug!(
|
debug!(
|
||||||
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
|
"[Filter] parse_query({:?}) -> prefix={:?}, query={:?}",
|
||||||
@@ -277,60 +282,8 @@ impl ProviderFilter {
|
|||||||
query: rest.to_string(),
|
query: rest.to_string(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
let exact = format!(":{}", name);
|
||||||
|
if trimmed == exact {
|
||||||
// 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 {
|
|
||||||
#[cfg(feature = "dev-logging")]
|
#[cfg(feature = "dev-logging")]
|
||||||
debug!(
|
debug!(
|
||||||
"[Filter] parse_query({:?}) -> partial prefix {:?}",
|
"[Filter] parse_query({:?}) -> partial prefix {:?}",
|
||||||
@@ -344,8 +297,24 @@ impl ProviderFilter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (prefix_str, type_id) in partial_plugin {
|
// Single-pass: try each plugin prefix as both full and partial
|
||||||
if trimmed == *prefix_str {
|
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());
|
let provider = ProviderType::Plugin(type_id.to_string());
|
||||||
#[cfg(feature = "dev-logging")]
|
#[cfg(feature = "dev-logging")]
|
||||||
debug!(
|
debug!(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ fn main() {
|
|||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
|
||||||
|
|
||||||
let sock = paths::socket_path();
|
let sock = paths::socket_path();
|
||||||
info!("Starting owlry-core daemon...");
|
info!("Starting owlryd daemon...");
|
||||||
|
|
||||||
// Ensure the socket parent directory exists
|
// Ensure the socket parent directory exists
|
||||||
if let Err(e) = paths::ensure_parent_dir(&sock) {
|
if let Err(e) = paths::ensure_parent_dir(&sock) {
|
||||||
@@ -18,7 +18,7 @@ fn main() {
|
|||||||
let server = match Server::bind(&sock) {
|
let server = match Server::bind(&sock) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to start owlry-core: {e}");
|
eprintln!("Failed to start owlryd: {e}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
// Clean up any double spaces that may have resulted from removing field codes
|
||||||
let mut cleaned = result.trim().to_string();
|
let trimmed = result.trim();
|
||||||
while cleaned.contains(" ") {
|
let mut cleaned = String::with_capacity(trimmed.len());
|
||||||
cleaned = cleaned.replace(" ", " ");
|
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
|
cleaned
|
||||||
@@ -271,4 +281,11 @@ mod tests {
|
|||||||
"env FOO=bar BAZ=qux myapp"
|
"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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1192
crates/owlry-core/src/providers/config_editor.rs
Normal file
1192
crates/owlry-core/src/providers/config_editor.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
|||||||
mod application;
|
mod application;
|
||||||
mod command;
|
mod command;
|
||||||
pub(crate) mod calculator;
|
pub(crate) mod calculator;
|
||||||
|
pub(crate) mod config_editor;
|
||||||
pub(crate) mod converter;
|
pub(crate) mod converter;
|
||||||
pub(crate) mod system;
|
pub(crate) mod system;
|
||||||
|
|
||||||
@@ -116,6 +117,11 @@ pub(crate) trait DynamicProvider: Send + Sync {
|
|||||||
fn provider_type(&self) -> ProviderType;
|
fn provider_type(&self) -> ProviderType;
|
||||||
fn query(&self, query: &str) -> Vec<LaunchItem>;
|
fn query(&self, query: &str) -> Vec<LaunchItem>;
|
||||||
fn priority(&self) -> u32;
|
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
|
/// Manages all providers and handles searching
|
||||||
@@ -196,25 +202,6 @@ impl ProviderManager {
|
|||||||
manager
|
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.
|
/// Create a self-contained ProviderManager from config.
|
||||||
///
|
///
|
||||||
/// Loads native plugins, creates core providers (Application + Command),
|
/// Loads native plugins, creates core providers (Application + Command),
|
||||||
@@ -327,6 +314,11 @@ impl ProviderManager {
|
|||||||
info!("Registered built-in converter provider");
|
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
|
// Built-in static providers
|
||||||
if config.providers.system {
|
if config.providers.system {
|
||||||
core_providers.push(Box::new(system::SystemProvider::new()));
|
core_providers.push(Box::new(system::SystemProvider::new()));
|
||||||
@@ -529,6 +521,14 @@ impl ProviderManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check built-in dynamic providers
|
||||||
|
for provider in &self.builtin_dynamic {
|
||||||
|
if provider.execute_action(command) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,22 +737,17 @@ impl ProviderManager {
|
|||||||
|
|
||||||
// Empty query (after checking special providers) - return frecency-sorted items
|
// Empty query (after checking special providers) - return frecency-sorted items
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
// Collect items from core providers
|
let mut scored_refs: Vec<(&LaunchItem, i64)> = self
|
||||||
let core_items = self
|
|
||||||
.providers
|
.providers
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| filter.is_active(p.provider_type()))
|
.filter(|p| filter.is_active(p.provider_type()))
|
||||||
.flat_map(|p| p.items().iter().cloned());
|
.flat_map(|p| p.items().iter())
|
||||||
|
.chain(
|
||||||
// Collect items from static native providers
|
self.static_native_providers
|
||||||
let native_items = self
|
.iter()
|
||||||
.static_native_providers
|
.filter(|p| filter.is_active(p.provider_type()))
|
||||||
.iter()
|
.flat_map(|p| p.items().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| {
|
.filter(|item| {
|
||||||
// Apply tag filter if present
|
// Apply tag filter if present
|
||||||
if let Some(tag) = tag_filter {
|
if let Some(tag) = tag_filter {
|
||||||
@@ -768,8 +763,15 @@ impl ProviderManager {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Combine widgets (already in results) with frecency items
|
// Partial sort: O(n) average to find top max_results, then O(k log k) to order them
|
||||||
results.extend(items);
|
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.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
results.truncate(max_results);
|
results.truncate(max_results);
|
||||||
return results;
|
return results;
|
||||||
@@ -777,7 +779,7 @@ impl ProviderManager {
|
|||||||
|
|
||||||
// Regular search with frecency boost and tag matching
|
// Regular search with frecency boost and tag matching
|
||||||
// Helper closure for scoring items
|
// 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
|
// Apply tag filter if present
|
||||||
if let Some(tag) = tag_filter
|
if let Some(tag) = tag_filter
|
||||||
&& !item.tags.iter().any(|t| t.to_lowercase().contains(tag))
|
&& !item.tags.iter().any(|t| t.to_lowercase().contains(tag))
|
||||||
@@ -824,33 +826,46 @@ impl ProviderManager {
|
|||||||
0
|
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 {
|
for provider in &self.providers {
|
||||||
if !filter.is_active(provider.provider_type()) {
|
if !filter.is_active(provider.provider_type()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for item in provider.items() {
|
for item in provider.items() {
|
||||||
if let Some(scored) = score_item(item) {
|
if let Some(score) = score_item(item) {
|
||||||
results.push(scored);
|
scored_refs.push((item, score));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search static native providers
|
|
||||||
for provider in &self.static_native_providers {
|
for provider in &self.static_native_providers {
|
||||||
if !filter.is_active(provider.provider_type()) {
|
if !filter.is_active(provider.provider_type()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for item in provider.items() {
|
for item in provider.items() {
|
||||||
if let Some(scored) = score_item(item) {
|
if let Some(score) = score_item(item) {
|
||||||
results.push(scored);
|
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.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
results.truncate(max_results);
|
results.truncate(max_results);
|
||||||
|
|
||||||
@@ -1217,23 +1232,4 @@ mod tests {
|
|||||||
assert_eq!(results[0].0.name, "Firefox");
|
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"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! Native plugins are loaded from `/usr/lib/owlry/plugins/` as `.so` files
|
//! Native plugins are loaded from `/usr/lib/owlry/plugins/` as `.so` files
|
||||||
//! and provide search providers via an ABI-stable interface.
|
//! and provide search providers via an ABI-stable interface.
|
||||||
|
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::Arc;
|
||||||
|
|
||||||
use log::debug;
|
use log::debug;
|
||||||
use owlry_plugin_api::{
|
use owlry_plugin_api::{
|
||||||
@@ -28,7 +28,7 @@ pub struct NativeProvider {
|
|||||||
/// Handle to the provider state in the plugin
|
/// Handle to the provider state in the plugin
|
||||||
handle: ProviderHandle,
|
handle: ProviderHandle,
|
||||||
/// Cached items (for static providers)
|
/// Cached items (for static providers)
|
||||||
items: RwLock<Vec<LaunchItem>>,
|
items: Vec<LaunchItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NativeProvider {
|
impl NativeProvider {
|
||||||
@@ -40,7 +40,7 @@ impl NativeProvider {
|
|||||||
plugin,
|
plugin,
|
||||||
info,
|
info,
|
||||||
handle,
|
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("!");
|
let is_special_query = query.starts_with("?SUBMENU:") || query.starts_with("!");
|
||||||
|
|
||||||
if self.info.provider_type != ProviderKind::Dynamic && !is_special_query {
|
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);
|
let api_items = self.plugin.query_provider(self.handle, query);
|
||||||
@@ -171,22 +171,11 @@ impl Provider for NativeProvider {
|
|||||||
items.len()
|
items.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
*self.items.write().unwrap() = items;
|
self.items = items;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn items(&self) -> &[LaunchItem] {
|
fn items(&self) -> &[LaunchItem] {
|
||||||
// This is tricky with RwLock - we need to return a reference but can't
|
&self.items
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "owlry-lua"
|
name = "owlry-lua"
|
||||||
version = "1.1.0"
|
version = "1.1.2"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
@@ -31,7 +31,7 @@ serde_json = "1.0"
|
|||||||
semver = "1"
|
semver = "1"
|
||||||
|
|
||||||
# HTTP client for plugins
|
# 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
|
# Math expression evaluation
|
||||||
meval = "0.2"
|
meval = "0.2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "owlry-rune"
|
name = "owlry-rune"
|
||||||
version = "1.1.0"
|
version = "1.1.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.90"
|
rust-version = "1.90"
|
||||||
description = "Rune scripting runtime for owlry plugins"
|
description = "Rune scripting runtime for owlry plugins"
|
||||||
@@ -22,7 +22,7 @@ log = "0.4"
|
|||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
|
|
||||||
# HTTP client for network API
|
# 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
|
# Serialization
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "owlry"
|
name = "owlry"
|
||||||
version = "1.0.5"
|
version = "1.0.6"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.90"
|
rust-version = "1.90"
|
||||||
description = "A lightweight, owl-themed application launcher for Wayland"
|
description = "A lightweight, owl-themed application launcher for Wayland"
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
// Compile GResource bundle for icons
|
// Compile GResource bundle for plugin-specific icons (weather, media, pomodoro)
|
||||||
glib_build_tools::compile_resources(
|
glib_build_tools::compile_resources(
|
||||||
&["src/resources/icons"],
|
&["src/resources/icons"],
|
||||||
"src/resources/icons.gresource.xml",
|
"src/resources/icons.gresource.xml",
|
||||||
"icons.gresource",
|
"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.gresource.xml");
|
||||||
println!("cargo:rerun-if-changed=src/resources/icons/");
|
println!("cargo:rerun-if-changed=src/resources/icons/");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem};
|
use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem};
|
||||||
|
|
||||||
/// IPC client that connects to the owlry-core daemon Unix socket
|
/// IPC client that connects to the owlryd daemon Unix socket
|
||||||
/// and provides typed methods for all IPC operations.
|
/// and provides typed methods for all IPC operations.
|
||||||
pub struct CoreClient {
|
pub struct CoreClient {
|
||||||
stream: UnixStream,
|
stream: UnixStream,
|
||||||
@@ -38,15 +38,15 @@ impl CoreClient {
|
|||||||
|
|
||||||
// Socket not available — try to start the daemon.
|
// Socket not available — try to start the daemon.
|
||||||
let status = std::process::Command::new("systemctl")
|
let status = std::process::Command::new("systemctl")
|
||||||
.args(["--user", "start", "owlry-core"])
|
.args(["--user", "start", "owlryd"])
|
||||||
.status()
|
.status()
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
io::Error::other(format!("failed to start owlry-core via systemd: {e}"))
|
io::Error::other(format!("failed to start owlryd via systemd: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(io::Error::other(format!(
|
return Err(io::Error::other(format!(
|
||||||
"systemctl --user start owlry-core exited with status {}",
|
"systemctl --user start owlryd exited with status {}",
|
||||||
status
|
status
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -507,9 +507,7 @@ impl MainWindow {
|
|||||||
search_entry.set_placeholder_text(Some(&format!("Filter {} actions...", display_name)));
|
search_entry.set_placeholder_text(Some(&format!("Filter {} actions...", display_name)));
|
||||||
|
|
||||||
// Display actions
|
// Display actions
|
||||||
while let Some(child) = results_list.first_child() {
|
results_list.remove_all();
|
||||||
results_list.remove(&child);
|
|
||||||
}
|
|
||||||
|
|
||||||
for item in &actions {
|
for item in &actions {
|
||||||
let row = ResultRow::new(item, "");
|
let row = ResultRow::new(item, "");
|
||||||
@@ -589,9 +587,7 @@ impl MainWindow {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Clear and repopulate
|
// Clear and repopulate
|
||||||
while let Some(child) = results_list.first_child() {
|
results_list.remove_all();
|
||||||
results_list.remove(&child);
|
|
||||||
}
|
|
||||||
|
|
||||||
for item in &filtered {
|
for item in &filtered {
|
||||||
let row = ResultRow::new(item, "");
|
let row = ResultRow::new(item, "");
|
||||||
@@ -702,9 +698,7 @@ impl MainWindow {
|
|||||||
if search_entry_for_stale.text().as_str() != raw_text_at_dispatch {
|
if search_entry_for_stale.text().as_str() != raw_text_at_dispatch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
while let Some(child) = results_list_cb.first_child() {
|
results_list_cb.remove_all();
|
||||||
results_list_cb.remove(&child);
|
|
||||||
}
|
|
||||||
|
|
||||||
let items = result.items;
|
let items = result.items;
|
||||||
let initial_count =
|
let initial_count =
|
||||||
@@ -739,9 +733,7 @@ impl MainWindow {
|
|||||||
tag.as_deref(),
|
tag.as_deref(),
|
||||||
);
|
);
|
||||||
|
|
||||||
while let Some(child) = results_list.first_child() {
|
results_list.remove_all();
|
||||||
results_list.remove(&child);
|
|
||||||
}
|
|
||||||
|
|
||||||
let initial_count = INITIAL_RESULTS.min(results.len());
|
let initial_count = INITIAL_RESULTS.min(results.len());
|
||||||
|
|
||||||
@@ -1247,9 +1239,7 @@ impl MainWindow {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Clear existing results
|
// Clear existing results
|
||||||
while let Some(child) = results_list.first_child() {
|
results_list.remove_all();
|
||||||
results_list.remove(&child);
|
|
||||||
}
|
|
||||||
|
|
||||||
let initial_count = INITIAL_RESULTS.min(results.len());
|
let initial_count = INITIAL_RESULTS.min(results.len());
|
||||||
|
|
||||||
|
|||||||
967
docs/superpowers/plans/2026-03-26-codebase-hardening.md
Normal file
967
docs/superpowers/plans/2026-03-26-codebase-hardening.md
Normal 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"
|
||||||
|
```
|
||||||
810
docs/superpowers/plans/2026-03-26-runtime-integration.md
Normal file
810
docs/superpowers/plans/2026-03-26-runtime-integration.md
Normal 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 ..."
|
||||||
|
```
|
||||||
876
docs/superpowers/plans/2026-03-28-config-editor.md
Normal file
876
docs/superpowers/plans/2026-03-28-config-editor.md
Normal 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.0–1.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.
|
||||||
1051
docs/superpowers/plans/2026-03-28-performance-hardening.md
Normal file
1051
docs/superpowers/plans/2026-03-28-performance-hardening.md
Normal file
File diff suppressed because it is too large
Load Diff
922
docs/superpowers/plans/2026-03-29-performance-optimization.md
Normal file
922
docs/superpowers/plans/2026-03-29-performance-optimization.md
Normal 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 |
|
||||||
142
docs/superpowers/specs/2026-03-26-codebase-hardening-design.md
Normal file
142
docs/superpowers/specs/2026-03-26-codebase-hardening-design.md
Normal 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
|
||||||
161
docs/superpowers/specs/2026-03-26-runtime-integration-design.md
Normal file
161
docs/superpowers/specs/2026-03-26-runtime-integration-design.md
Normal 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)
|
||||||
187
docs/superpowers/specs/2026-03-28-config-editor-design.md
Normal file
187
docs/superpowers/specs/2026-03-28-config-editor-design.md
Normal 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.0–1.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.
|
||||||
21
justfile
21
justfile
@@ -60,17 +60,17 @@ install-local:
|
|||||||
|
|
||||||
echo "Installing binaries..."
|
echo "Installing binaries..."
|
||||||
sudo install -Dm755 target/release/owlry /usr/bin/owlry
|
sudo install -Dm755 target/release/owlry /usr/bin/owlry
|
||||||
sudo install -Dm755 target/release/owlry-core /usr/bin/owlry-core
|
sudo install -Dm755 target/release/owlryd /usr/bin/owlryd
|
||||||
|
|
||||||
echo "Installing runtimes..."
|
echo "Installing runtimes..."
|
||||||
[ -f target/release/libowlry_lua.so ] && sudo install -Dm755 target/release/libowlry_lua.so /usr/lib/owlry/runtimes/liblua.so
|
[ -f target/release/libowlry_lua.so ] && sudo install -Dm755 target/release/libowlry_lua.so /usr/lib/owlry/runtimes/liblua.so
|
||||||
[ -f target/release/libowlry_rune.so ] && sudo install -Dm755 target/release/libowlry_rune.so /usr/lib/owlry/runtimes/librune.so
|
[ -f target/release/libowlry_rune.so ] && sudo install -Dm755 target/release/libowlry_rune.so /usr/lib/owlry/runtimes/librune.so
|
||||||
|
|
||||||
echo "Installing systemd service files..."
|
echo "Installing systemd service files..."
|
||||||
[ -f systemd/owlry-core.service ] && sudo install -Dm644 systemd/owlry-core.service /usr/lib/systemd/user/owlry-core.service
|
[ -f systemd/owlryd.service ] && sudo install -Dm644 systemd/owlryd.service /usr/lib/systemd/user/owlryd.service
|
||||||
[ -f systemd/owlry-core.socket ] && sudo install -Dm644 systemd/owlry-core.socket /usr/lib/systemd/user/owlry-core.socket
|
[ -f systemd/owlryd.socket ] && sudo install -Dm644 systemd/owlryd.socket /usr/lib/systemd/user/owlryd.socket
|
||||||
|
|
||||||
echo "Done. Start daemon: systemctl --user enable --now owlry-core.service"
|
echo "Done. Start daemon: systemctl --user enable --now owlryd.service"
|
||||||
|
|
||||||
# === Version Management ===
|
# === Version Management ===
|
||||||
|
|
||||||
@@ -328,7 +328,7 @@ bump-meta new_version:
|
|||||||
|
|
||||||
# === Testing ===
|
# === Testing ===
|
||||||
|
|
||||||
# Test a specific AUR package build locally
|
# Quick local build test (no chroot, uses host deps)
|
||||||
aur-test-pkg pkg:
|
aur-test-pkg pkg:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -337,3 +337,14 @@ aur-test-pkg pkg:
|
|||||||
makepkg -sf
|
makepkg -sf
|
||||||
echo "Package built successfully!"
|
echo "Package built successfully!"
|
||||||
ls -lh *.pkg.tar.zst
|
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
329
scripts/aur-local-test
Executable 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!"
|
||||||
@@ -5,7 +5,7 @@ After=graphical-session.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=/usr/bin/owlry-core
|
ExecStart=/usr/bin/owlryd
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
Environment=RUST_LOG=warn
|
Environment=RUST_LOG=warn
|
||||||
Reference in New Issue
Block a user