From 163e68af9ede8d8a567bf15c92fd9ef3d5367304 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 01:36:55 +0200 Subject: [PATCH 01/23] docs(v2): lock down restructure plan Comprehensive plan covering all 5 phases of the v2 restructure: decisions log, target shape, CLI layout, feature naming, conversion notes, breaking changes, deferred questions, and acceptance checklist. This document is the source of truth for the v2 work; future sessions read this first to recover context. --- docs/RESTRUCTURE-V2.md | 440 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 docs/RESTRUCTURE-V2.md diff --git a/docs/RESTRUCTURE-V2.md b/docs/RESTRUCTURE-V2.md new file mode 100644 index 0000000..c2c9251 --- /dev/null +++ b/docs/RESTRUCTURE-V2.md @@ -0,0 +1,440 @@ +# Owlry v2 Restructure Plan + +**Status:** in progress on branch `v2` (cut from `main @ 1caa050`) +**Target version:** `owlry 2.0.0` +**Scope:** repository collapse, C-ABI removal, runtime consolidation, CLI cleanup, single AUR package +**Originating issue:** [#5 uuctl launcher not working](https://somegit.dev/Owlibou/owlry/issues/5) — closed by deletion of the C-ABI loader that caused it + +--- + +## 0. Decisions log + +All design decisions agreed before work started. These are load-bearing — do not silently change them. + +| # | Decision | Rationale | +|---|---|---| +| D1 | Collapse to **1 AUR package**: `owlry` | 14 PKGBUILDs maintained in lockstep was the root cost of every API bump. Native plugin distribution was theoretical (no third-party shipped any). | +| D2 | **Drop the C-ABI plugin system entirely** | Issue #5's root cause: strict `api_version != API_VERSION` rejection. Compiling providers in eliminates the failure mode. | +| D3 | **Drop Rune runtime** | Only one user plugin (`hyprshutdown`) uses Rune; will be ported to Lua. 118 MB dylib for one plugin is not earned. | +| D4 | **Use Lua for config + plugins** via `mlua` (Lua 5.4, bundled) — *Phase 3+* | Single language for both configuration and user extension. LuaJIT perf is irrelevant for a config language. | +| D5 | **Configs-as-plugins** model | `owlry.provider {}` in `init.lua` registers a runtime provider treated identically to a built-in. No separate `~/.config/owlry/plugins/` dir. | +| D6 | **Daemon as subcommand** with `-d` short alias: `owlry daemon` or `owlry -d` | Single binary; `owlryd` no longer exists. systemd unit invokes `owlry -d`. | +| D7 | **`-m auto`** as explicit alias for the implicit no-flag default | Today's `accept_all` behavior — all enabled providers active, tabs cycle. Pinned with an integration test so refactors can't regress it. | +| D8 | **Hard cut on TOML config** in a later phase | Migrator command `owlry migrate-config` reads existing TOML + script plugins, emits `init.lua`. One-shot; TOML reader deleted in the same release that ships Lua. | +| D9 | **AUR ships `--features full`** | Defaults in `Cargo.toml` are minimal; AUR PKGBUILD enables everything. End-user picks at runtime via config, not at install time. | +| D10 | **Runtime activation is config-driven** | Compiled-in ≠ enabled. Config (TOML now, Lua later) decides which providers run. | +| D11 | **Drop `config_editor` provider** (1127 LOC) | An in-launcher settings editor is replaced by editing `init.lua` + `owlry config validate`. | +| D12 | **Drop `scripts` provider** | Replaced by `owlry.provider {}` in Lua config (Phase 3+). | +| D13 | **Rename `sys` → `power`** | "sys" collides mentally with "systemd". `:sys` kept as alias. | +| D14 | **Keep submenu protocol** | Used by systemd provider for service actions. Becomes a method on the `Provider` trait. | + +--- + +## 1. Target shape (post-Phase 2 — what 2.0.0 ships as) + +### Repo layout + +``` +owlry/ +├── Cargo.toml -- workspace with single member +├── crates/owlry/ +│ ├── Cargo.toml -- one binary, features per provider +│ └── src/ +│ ├── main.rs -- subcommand router +│ ├── cli.rs -- clap defs +│ ├── server.rs -- daemon mode (was owlry-core/src/server.rs) +│ ├── client.rs -- IPC client to daemon (UI mode) +│ ├── backend.rs -- abstracts daemon vs local +│ ├── ipc.rs -- Request/Response types +│ ├── filter.rs -- ProviderFilter +│ ├── paths.rs -- XDG paths +│ ├── notify.rs -- desktop notifications +│ ├── theme.rs -- CSS loading +│ ├── config/ -- config loader (TOML in Phase 1, Lua in Phase 3) +│ ├── data/ -- FrecencyStore +│ ├── providers/ -- ALL providers, each gated by feature +│ │ ├── mod.rs -- ProviderManager, Provider traits, ProviderType +│ │ ├── application.rs (feature: app) +│ │ ├── command.rs (feature: cmd) +│ │ ├── calculator.rs (feature: calc) +│ │ ├── converter/ (feature: conv) +│ │ ├── power.rs (feature: power, was sys) +│ │ ├── dmenu.rs (feature: dmenu, moved from owlry/src/providers/) +│ │ ├── bookmarks.rs (feature: bookmarks) +│ │ ├── clipboard.rs (feature: clipboard) +│ │ ├── emoji.rs (feature: emoji) +│ │ ├── ssh.rs (feature: ssh) +│ │ ├── systemd.rs (feature: systemd, type_id "uuctl") +│ │ ├── websearch.rs (feature: websearch) +│ │ ├── filesearch.rs (feature: filesearch) +│ │ ├── weather.rs (feature: weather) +│ │ ├── media.rs (feature: media) +│ │ └── pomodoro.rs (feature: pomodoro) +│ └── ui/ -- GTK4 client +├── data/ +│ ├── config.example.toml -- shipped defaults (Phase 1) +│ └── default-config.lua -- shipped defaults (Phase 3+) +├── systemd/ +│ ├── owlryd.service -- renamed to owlry.service in Phase 2; ExecStart=/usr/bin/owlry -d +│ └── owlryd.socket -- renamed to owlry.socket +└── aur/owlry/PKGBUILD -- the only AUR package +``` + +### Deleted entirely + +| Path | Reason | +|---|---| +| `crates/owlry-core/` | Folded into `crates/owlry/` | +| `crates/owlry-plugin-api/` | C-ABI gone | +| `crates/owlry-lua/` | mlua compiled in via Phase 3 | +| `crates/owlry-rune/` | Rune dropped (D3) | +| `crates/owlry-core/src/plugins/` (entire dir) | C-ABI loader, runtime loader, manifest, registry, watcher, error | +| `crates/owlry-core/src/providers/native_provider.rs` | C-ABI bridge | +| `crates/owlry-core/src/providers/lua_provider.rs` | Old Lua bridge | +| `crates/owlry-core/src/providers/config_editor.rs` | D11 | +| `crates/owlry/src/plugin_commands.rs` (1296 LOC) | No plugin CLI | +| `aur/owlry-core/`, `aur/owlry-lua/`, `aur/owlry-rune/` | Folded into `aur/owlry/` | +| `aur/owlry-plugin-*` (none yet in this repo) | n/a | +| `aur/owlry-meta-*` (essentials, widgets, tools, full) | Redundant | +| `owlry-plugins/` sibling repo | Archived on somegit.dev | + +--- + +## 2. CLI shape (post-Phase 1) + +``` +owlry launch UI, auto mode (default) +owlry -m auto launch UI, auto mode (explicit alias) +owlry -m launch UI in single-provider mode +owlry --profile launch UI with a named profile + +owlry daemon run daemon (alias: owlry -d) +owlry dmenu [-p ] dmenu mode (was: owlry -m dmenu) +owlry doctor diagnostics: providers, config, socket, runtime +owlry providers [] list providers (or show one) +owlry config validate check config for errors +owlry config show print resolved effective config +owlry migrate-config TOML → init.lua (Phase 3+) +``` + +### Removed CLI surface + +The entire `owlry plugin ...` subcommand tree (~1296 LOC in `plugin_commands.rs`) is deleted: +`list`, `search`, `info`, `install`, `remove`, `update`, `enable`, `disable`, `create`, `validate`, `runtimes`, `run`, `commands`. +There is no plugin installation to manage — features are compiled in. + +--- + +## 3. Feature names + descriptions + +Final naming for cargo features and corresponding provider IDs: + +| Feature | CLI mode / prefix | What it does | +|---|---|---| +| `app` | `:app` | XDG desktop applications | +| `cmd` | `:cmd` | Executables on `$PATH` | +| `calc` | `:calc` / `=` | Calculator (math expressions, e.g. `= 2+2`) | +| `conv` | `:conv` | Unit & currency converter (e.g. `5 ft to m`) | +| `power` | `:power` (`:sys` alias) | Shutdown, reboot, logout, suspend, lock — was `sys` | +| `dmenu` | (subcommand) | Pipe-based selection — stdin in, selection out | +| `bookmarks` | `:bm` | Browser bookmarks (Firefox, Chromium) | +| `clipboard` | `:clip` | Clipboard history via `cliphist`/`wl-clipboard` | +| `emoji` | `:emoji` | Emoji picker (writes via `wl-clipboard`) | +| `ssh` | `:ssh` | SSH hosts from `~/.ssh/config` | +| `systemd` | `:systemd` (`:uuctl` alias) | systemd user units (type_id stays "uuctl" for config compat) | +| `websearch` | `:web` / `?` | Web search (DDG, Google, custom engines) | +| `filesearch` | `:file` / `/` | File search via `fd` or `mlocate` | +| `weather` | (widget) | Weather widget at top of results | +| `media` | (widget) | MPRIS media player controls | +| `pomodoro` | (widget) | Pomodoro focus timer widget | + +### Cargo feature groups + +```toml +[features] +default = ["app", "cmd", "calc", "conv", "power", "dmenu"] +full = [ + "app", "cmd", "calc", "conv", "power", "dmenu", + "bookmarks", "clipboard", "emoji", "ssh", "systemd", + "websearch", "filesearch", "weather", "media", "pomodoro", +] +dev-logging = [] +``` + +AUR PKGBUILD builds with `--features full`. + +--- + +## 4. Runtime config (Phase 1: TOML, Phase 3+: Lua) + +Three orthogonal axes: + +1. **Compiled in** — Cargo features at build time. AUR ships `full`. +2. **Enabled** — Config decides which providers actually run. +3. **Selected** — `-m` / `--profile` / Tab key narrows the active set at use time. + +### Phase 1 (TOML, kept temporarily) + +```toml +[general] +tabs = ["app", "cmd", "calc", "conv", "power"] # what shows in auto mode + +[providers] +applications = true +commands = true +calculator = true +converter = true +power = true # was `system` +bookmarks = false # opt-in +clipboard = false +# ...etc +``` + +### Phase 3+ (Lua, target) + +```lua +local owlry = require("owlry") + +owlry.set { + theme = "apex-neon", + width = 850, height = 650, + tabs = { "app", "cmd", "power" }, +} + +owlry.providers { "app", "cmd", "calc", "conv", "power", "bookmarks", "systemd" } + +owlry.provider { + id = "hs", prefix = ":hs", tab_label = "Shutdown", + items = function(_q) + return { + { name = "Lock", command = "hyprlock" }, + { name = "Shutdown", command = "systemctl poweroff" }, + { name = "Reboot", command = "systemctl reboot" }, + } + end, +} +``` + +--- + +## 5. Phased plan + +### Phase 1 — Repo collapse (current) + +Goal: single workspace member, single binary, TOML config still works, C-ABI gone, all built-in providers compiled in behind features. + +Subtasks (tracked in TaskList): + +1. ✅ Inventory: map Provider trait + plugin shapes +2. **Workspace skeleton: collapse to single crate** — move `owlry-core/src/*` into `owlry/src/`, absorb deps, remove other crates from workspace +3. **Delete C-ABI plugin system** — `plugins/` dir, `native_provider.rs`, `lua_provider.rs`, `owlry-plugin-api` crate; strip `ProviderManager::new_with_config()` of plugin loading +4. **Delete Rune + Lua runtime crates** — `crates/owlry-rune/`, `crates/owlry-lua/` +5. **Delete config_editor + scripts providers** +6. **Convert 11 plugins to native Provider impls** — pull source from `owlry-plugins/crates/owlry-plugin-*`, convert `extern "C"` vtable → `impl Provider` / `impl DynamicProvider`. Per-plugin mechanical work. +7. **Wire cargo features per provider** — `#[cfg(feature = "...")]` gating; `default` / `full` feature groups +8. **Rename `sys` → `power`** — file, type_id (in CLI mode mapping table), `:sys` kept as alias, config key `providers.system` → `providers.power` (with TOML migration shim that reads the old name) +9. **CLI restructure** — new clap shape (subcommands `daemon`, `dmenu`, `doctor`, `providers`, `config`, `migrate-config`); drop entire `plugin` subcommand tree; daemon mode via `owlry -d` / `owlry daemon` +10. **Auto-mode integration test** — `tests/auto_mode.rs` asserts the no-flag default still surfaces results from all enabled providers and tabs match `general.tabs` +11. **Phase 1 final build + smoke** — `cargo check --workspace`, `cargo build --release`, `cargo test`, runtime socket smoke + +### Phase 2 — AUR republish as 2.0.0 + +Goal: ship the collapsed binary to users. Issue #5 closes by virtue of the C-ABI being gone. + +Steps: + +- Update `aur/owlry/PKGBUILD`: + - `pkgver=2.0.0` + - `replaces=('owlry-core' 'owlry-lua' 'owlry-rune' 'owlry-plugin-bookmarks' 'owlry-plugin-clipboard' 'owlry-plugin-emoji' 'owlry-plugin-filesearch' 'owlry-plugin-media' 'owlry-plugin-pomodoro' 'owlry-plugin-scripts' 'owlry-plugin-ssh' 'owlry-plugin-systemd' 'owlry-plugin-weather' 'owlry-plugin-websearch' 'owlry-meta-essentials' 'owlry-meta-widgets' 'owlry-meta-tools' 'owlry-meta-full')` + - `conflicts=()` + - `provides=()` + - `build()` uses `cargo build --release --features full` + - `package()` installs single binary, single systemd service+socket, default config +- Delete the 14 other PKGBUILDs under `aur/` +- `git tag owlry-v2.0.0` +- Push, build, `aur-publish` +- Archive `owlry-plugins` sibling repo on somegit.dev (read-only, history preserved) +- Comment on issue #5: "fixed by 2.0 rewrite; the C-ABI plugin loader that produced this class of bug has been removed" + +### Phase 3 — Lua config core (later release: 2.1.0 or 3.0.0) + +Goal: replace TOML with Lua-driven config. Hyprland-style configs-as-code. + +Steps: + +- Add `mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"] }` dependency +- Build the `owlry.*` Lua API surface: + - `owlry.set { ... }` — global settings (theme, dimensions, tabs) + - `owlry.providers { ... }` — enable list (subset of compiled-in features) + - `owlry.provider { id, prefix, tab_label, items, ... }` — register a runtime provider + - `owlry.bind(key, action)` — keybinding overrides + - `owlry.theme(name | { ... })` — theme selection or inline definition +- Resolve config: `~/.config/owlry/init.lua` if present; else fall back to shipped `data/default-config.lua` +- `owlry migrate-config` subcommand: read existing `config.toml` (+ `~/.config/owlry/plugins/*` if any) and emit equivalent `init.lua` +- Wire `owlry config validate` and `owlry config show` to surface Lua errors and resolved state +- Decision pending: hard cut TOML in 3.0.0 vs. dual-read in 2.1.0 → cut in 3.0.0 + +### Phase 4 — Configs-as-plugins (same release as Phase 3) + +Goal: Lua-registered providers are first-class. Drop the script plugin discovery dir. + +Steps: + +- `owlry.provider {}` creates a `LuaProvider` that wraps the supplied Lua closures and implements the `Provider` trait +- Remove the `~/.config/owlry/plugins/` filesystem discovery entirely +- The shipped `default-config.lua` showcases at least one inline provider as documentation + +### Phase 5 — Hygiene + +Goal: clean up debt that the collapse uncovered. + +Steps: + +- Update `CLAUDE.md` — binary name (`owlry`, not `owlry-core`/`owlryd`), workspace layout, drop "Phase 5 pending" wording +- Update `README.md` — new CLI, new install instructions, drop AUR package list +- Refresh `ROADMAP.md` +- Split files >1000 LOC: + - `providers/mod.rs` — separate `ProviderType` / `LaunchItem` / `ItemSource` from `ProviderManager` + - `ui/main_window.rs` — input handling, render, signal wiring as separate modules + - `providers/converter/units.rs` — move data tables to a `units.toml` resource +- Fix double-daemon spawn: rely on socket activation only; add `flock` guard in `main.rs` if needed +- Make sure systemd unit name is `owlry.service` (or `owlryd.service`?) — decide before AUR ship + +--- + +## 6. Provider-by-provider conversion notes (Phase 1.6) + +Mechanical pattern for each plugin: drop `extern "C"` exports, `PluginItem` → `LaunchItem`, opaque `ProviderHandle` → struct field on a `Provider` impl. + +### `provider_kind`-driven categorization (current state) + +| Plugin | Kind | Submenu? | Per-keystroke? | Notes | +|---|---|---|---|---| +| bookmarks | Static | no | no | SQLite for Firefox; bundled rusqlite | +| clipboard | Static | yes (?) | no | Uses `cliphist`, `wl-clipboard` | +| emoji | Static | no | no | Bundled emoji data; writes via `wl-clipboard` | +| ssh | Static | no | no | Parses `~/.ssh/config` | +| systemd | Static | **yes** | no | type_id "uuctl"; SUBMENU: protocol for service actions | +| scripts | DELETE | — | — | Replaced by Lua config (D12) | +| websearch | Dynamic | no | yes | per-keystroke; `?` trigger | +| filesearch | Dynamic | no | yes | per-keystroke; `/` trigger; `fd` or `mlocate` | +| weather | Widget | no | no | Top-of-results; network call on refresh | +| media | Widget | yes | no | MPRIS control via submenu actions | +| pomodoro | Widget | yes | no | Start/pause/reset via submenu actions | + +### Submenu mechanism on `Provider` trait + +Add to `pub trait Provider`: + +```rust +/// Returns submenu actions for the given encoded data string, or empty if none. +/// Called when a user selects an item whose command field starts with "SUBMENU:". +fn submenu_actions(&self, _data: &str) -> Vec { Vec::new() } + +/// Handle a plugin-defined action command (e.g. "POMODORO:start"). +/// Returns true if handled. +fn execute_action(&self, _command: &str) -> bool { false } + +/// Optional UI hints — replace today's hardcoded match table. +fn prefix(&self) -> Option<&str> { None } +fn icon(&self) -> Option<&str> { None } +fn position(&self) -> ProviderPosition { ProviderPosition::Normal } +fn priority(&self) -> u32 { 0 } +``` + +`ProviderManager::query_submenu_actions` and `execute_plugin_action` route to these trait methods instead of the C-ABI `NativeProvider`. + +--- + +## 7. Breaking changes / migration impact + +### What users lose at 2.0.0 + +- 14 separate AUR packages → 1 (paru handles via `replaces`/`conflicts`) +- Ability to install a subset of plugins via AUR (still possible via `cargo install --no-default-features --features ...`) +- Rune scripting (the user's `hyprshutdown` plugin breaks; will be ported to Lua in Phase 3, or compiled into a small custom build) +- Dynamic plugin loading at runtime (was never used by anyone outside Owlibou) +- `~/.config/owlry/plugins/` directory (Phase 4 — but already orphaned at 2.0.0 since Rune is gone) + +### What stays unchanged for users + +- Launcher behavior, keybindings, themes, search prefixes +- Mode flags: `-m app`, `-m systemd`, `-m uuctl` (alias), `-m power` (was `-m sys`) +- TOML config compatibility through 2.x — until Phase 3 hard-cut to Lua +- Socket protocol (mostly — `Request::PluginList` is dropped; `Submenu` and `PluginAction` stay) +- systemd integration (unit file invokes `owlry -d` instead of `owlryd`) + +### `replaces=()` array for `aur/owlry/PKGBUILD` (2.0.0) + +``` +owlry-core +owlry-lua +owlry-rune +owlry-plugin-bookmarks +owlry-plugin-clipboard +owlry-plugin-emoji +owlry-plugin-filesearch +owlry-plugin-media +owlry-plugin-pomodoro +owlry-plugin-scripts +owlry-plugin-ssh +owlry-plugin-systemd +owlry-plugin-weather +owlry-plugin-websearch +owlry-meta-essentials +owlry-meta-widgets +owlry-meta-tools +owlry-meta-full +``` + +--- + +## 8. Open questions / decisions deferred to later phases + +| Question | Defer to | Notes | +|---|---|---| +| systemd unit name: `owlry.service` vs keep `owlryd.service`? | Phase 2 | Probably rename for consistency; PKGBUILD handles old name via `backup` field | +| Submenu protocol redesign (`SUBMENU:type:data` string-encoded → typed IPC variant) | Phase 5 | Works today; not blocking | +| Drop daemon entirely (collapse into single-process UI)? | Post-2.0 | Profile first; daemon's perf justification is dubious but not measured | +| TOML reader removal timing — 2.1.0 dual-read or 3.0.0 hard cut? | Phase 3 entry | Hard cut is cleaner; one migration moment | +| LuaProvider sandboxing model — what stdlib subset can user `init.lua` access? | Phase 3 | At minimum: filesystem read, process spawn (it's a launcher), no network by default | +| Where do widgets (`weather`, `media`, `pomodoro`) live in the UI after the rewrite? | Phase 1.6 | Today they're "position: Widget" rendered above results; keep that | +| Hot-reload of `init.lua` on save? | Phase 3 | Nice-to-have; `notify` crate is already a dep | + +--- + +## 9. Build / verify checklist (Phase 1 acceptance) + +Phase 1 is "done" when all of these pass: + +- [ ] `cargo check --workspace` (no warnings on default features) +- [ ] `cargo check --workspace --all-features` (no warnings) +- [ ] `cargo build --release --features full` +- [ ] `cargo test --workspace --all-features` +- [ ] `cargo clippy --workspace --all-features -- -D warnings` +- [ ] `cargo fmt --all --check` +- [ ] Workspace contains only `crates/owlry` member +- [ ] `crates/owlry-core/`, `crates/owlry-plugin-api/`, `crates/owlry-lua/`, `crates/owlry-rune/` no longer exist +- [ ] No occurrences of `owlry_plugin_api`, `mlua`, `libloading`, `Rune`, `liblua.so`, `librune.so` outside historical docs +- [ ] `target/release/owlry` runs: + - `owlry --help` shows new subcommand structure + - `owlry doctor` reports providers + config + socket status + - `owlry -d` starts daemon; socket listens at `$XDG_RUNTIME_DIR/owlry/owlry.sock` + - `owlry -m systemd` shows systemd units (Issue #5 fixed locally) + - `owlry` (no flags) shows auto mode with results from all enabled providers, tabs match `general.tabs` +- [ ] Integration test `tests/auto_mode.rs` passes +- [ ] No daemon double-spawn (verify `pgrep -af owlry` shows one process when launched normally) + +--- + +## 10. Working notes (mutable) + +This section captures in-progress state. Update freely as work proceeds. + +- **Branch:** `v2`, cut from `main @ 1caa050` on 2026-05-13 +- **Current task:** #3 (Delete C-ABI plugin system) — in progress +- **Stray processes from inventory phase:** + - PIDs 3042, 3278 — pre-existing `owlryd` (double-spawn bug; will resolve via Phase 5) + - PID 594897 — test daemon from inventory probe; harness denied kill; resolves at next reboot or user kill +- **Hyprland exec-once for owlryd:** suspected source of double-spawn; verify and remove during Phase 5 +- **`plugin-api-v1.0.1` git tag:** still has `API_VERSION = 3`. Moot once C-ABI is deleted. From 2fc976b969c1e7d5879aaf5dfd5f36de1bdf4230 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 01:45:21 +0200 Subject: [PATCH 02/23] docs(v2): resolve section 8 open questions as D15-D21 - D15: rename systemd unit owlryd.{service,socket} -> owlry.{...} - D16: defer submenu protocol redesign to Phase 5 - D17: keep daemon (cold-start cost justifies it) - D18: TOML reader hard cut at 3.0.0 - D19: Lua sandbox: fs read + process spawn; no network default - D20: widgets (weather, media, pomodoro) on hold; excluded from Phase 1 - D21: hot-reload init.lua on save (Phase 3) Phase 1 plugin conversion shrinks from 11 -> 8 plugins (widgets dropped). --- docs/RESTRUCTURE-V2.md | 55 +++++++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/docs/RESTRUCTURE-V2.md b/docs/RESTRUCTURE-V2.md index c2c9251..aa8e738 100644 --- a/docs/RESTRUCTURE-V2.md +++ b/docs/RESTRUCTURE-V2.md @@ -27,6 +27,13 @@ All design decisions agreed before work started. These are load-bearing — do n | D12 | **Drop `scripts` provider** | Replaced by `owlry.provider {}` in Lua config (Phase 3+). | | D13 | **Rename `sys` → `power`** | "sys" collides mentally with "systemd". `:sys` kept as alias. | | D14 | **Keep submenu protocol** | Used by systemd provider for service actions. Becomes a method on the `Provider` trait. | +| D15 | **Rename systemd unit `owlryd.{service,socket}` → `owlry.{service,socket}`** | Consistent with single-binary collapse. PKGBUILD ships new names; old units cleaned up via `replaces=()` upgrade path. | +| D16 | **Submenu protocol redesign deferred to Phase 5** | Today's `SUBMENU:type:data` string encoding works. Typed IPC variant is hygiene, not blocking. | +| D17 | **Keep the daemon** | Cold-start cost (XDG scan, frecency load, provider warm-up) is the real reason the daemon exists. Single-process collapse remains an option to revisit post-2.0 if profiling proves it's free. | +| D18 | **TOML reader: hard cut at 3.0.0** | One migration moment. 2.x stays TOML-only; 3.0 ships Lua-only with `owlry migrate-config` shipping in 2.x as preview. | +| D19 | **Lua sandbox** (Phase 3): filesystem read + process spawn allowed; no network by default | Launcher needs to read configs and spawn commands. Network access (HTTP, sockets) requires explicit config opt-in. | +| D20 | **Widget providers (`weather`, `media`, `pomodoro`) on hold** | UI positioning unresolved. **Excluded from Phase 1 conversion entirely** — not pulled in, not compiled in. Revisit as a dedicated workstream after 2.0 ships. | +| D21 | **Hot-reload of `init.lua` on save** (Phase 3) | `notify` crate already a dep. Watch `~/.config/owlry/init.lua` and any `require`d files; re-run config eval on change. | --- @@ -66,10 +73,8 @@ owlry/ │ │ ├── ssh.rs (feature: ssh) │ │ ├── systemd.rs (feature: systemd, type_id "uuctl") │ │ ├── websearch.rs (feature: websearch) -│ │ ├── filesearch.rs (feature: filesearch) -│ │ ├── weather.rs (feature: weather) -│ │ ├── media.rs (feature: media) -│ │ └── pomodoro.rs (feature: pomodoro) +│ │ └── filesearch.rs (feature: filesearch) +│ │ -- weather, media, pomodoro: deferred (D20) │ └── ui/ -- GTK4 client ├── data/ │ ├── config.example.toml -- shipped defaults (Phase 1) @@ -144,9 +149,9 @@ Final naming for cargo features and corresponding provider IDs: | `systemd` | `:systemd` (`:uuctl` alias) | systemd user units (type_id stays "uuctl" for config compat) | | `websearch` | `:web` / `?` | Web search (DDG, Google, custom engines) | | `filesearch` | `:file` / `/` | File search via `fd` or `mlocate` | -| `weather` | (widget) | Weather widget at top of results | -| `media` | (widget) | MPRIS media player controls | -| `pomodoro` | (widget) | Pomodoro focus timer widget | +| ~~`weather`~~ | — | **Deferred (D20)** — not in 2.0.0 | +| ~~`media`~~ | — | **Deferred (D20)** — not in 2.0.0 | +| ~~`pomodoro`~~ | — | **Deferred (D20)** — not in 2.0.0 | ### Cargo feature groups @@ -156,7 +161,8 @@ default = ["app", "cmd", "calc", "conv", "power", "dmenu"] full = [ "app", "cmd", "calc", "conv", "power", "dmenu", "bookmarks", "clipboard", "emoji", "ssh", "systemd", - "websearch", "filesearch", "weather", "media", "pomodoro", + "websearch", "filesearch", + # widgets (weather, media, pomodoro): deferred (D20) ] dev-logging = [] ``` @@ -230,7 +236,7 @@ Subtasks (tracked in TaskList): 3. **Delete C-ABI plugin system** — `plugins/` dir, `native_provider.rs`, `lua_provider.rs`, `owlry-plugin-api` crate; strip `ProviderManager::new_with_config()` of plugin loading 4. **Delete Rune + Lua runtime crates** — `crates/owlry-rune/`, `crates/owlry-lua/` 5. **Delete config_editor + scripts providers** -6. **Convert 11 plugins to native Provider impls** — pull source from `owlry-plugins/crates/owlry-plugin-*`, convert `extern "C"` vtable → `impl Provider` / `impl DynamicProvider`. Per-plugin mechanical work. +6. **Convert 8 plugins to native Provider impls** — bookmarks, clipboard, emoji, ssh, systemd, websearch, filesearch. Pull source from `owlry-plugins/crates/owlry-plugin-*`, convert `extern "C"` vtable → `impl Provider` / `impl DynamicProvider`. Per-plugin mechanical work. **Widgets (weather, media, pomodoro) excluded per D20; scripts excluded per D12.** 7. **Wire cargo features per provider** — `#[cfg(feature = "...")]` gating; `default` / `full` feature groups 8. **Rename `sys` → `power`** — file, type_id (in CLI mode mapping table), `:sys` kept as alias, config key `providers.system` → `providers.power` (with TOML migration shim that reads the old name) 9. **CLI restructure** — new clap shape (subcommands `daemon`, `dmenu`, `doctor`, `providers`, `config`, `migrate-config`); drop entire `plugin` subcommand tree; daemon mode via `owlry -d` / `owlry daemon` @@ -318,9 +324,9 @@ Mechanical pattern for each plugin: drop `extern "C"` exports, `PluginItem` → | scripts | DELETE | — | — | Replaced by Lua config (D12) | | websearch | Dynamic | no | yes | per-keystroke; `?` trigger | | filesearch | Dynamic | no | yes | per-keystroke; `/` trigger; `fd` or `mlocate` | -| weather | Widget | no | no | Top-of-results; network call on refresh | -| media | Widget | yes | no | MPRIS control via submenu actions | -| pomodoro | Widget | yes | no | Start/pause/reset via submenu actions | +| ~~weather~~ | DEFER | — | — | **D20** — UI positioning unresolved | +| ~~media~~ | DEFER | — | — | **D20** — UI positioning unresolved | +| ~~pomodoro~~ | DEFER | — | — | **D20** — UI positioning unresolved | ### Submenu mechanism on `Provider` trait @@ -389,17 +395,26 @@ owlry-meta-full --- -## 8. Open questions / decisions deferred to later phases +## 8. Open questions — RESOLVED + +All section-8 questions from the original plan have been answered. Captured as D15–D21 in section 0. + +| Original question | Resolution | Recorded as | +|---|---|---| +| systemd unit name | Rename to `owlry.{service,socket}` | D15 | +| Submenu protocol redesign | Deferred to Phase 5 | D16 | +| Drop daemon entirely? | No — cold-start cost justifies it | D17 | +| TOML reader removal timing | Hard cut at 3.0.0 | D18 | +| Lua sandbox model | fs read + process spawn allowed; no network by default | D19 | +| Widget positioning | On hold; widgets excluded from Phase 1 | D20 | +| Hot-reload init.lua | Yes (Phase 3) | D21 | + +### Newly open (post-resolution) | Question | Defer to | Notes | |---|---|---| -| systemd unit name: `owlry.service` vs keep `owlryd.service`? | Phase 2 | Probably rename for consistency; PKGBUILD handles old name via `backup` field | -| Submenu protocol redesign (`SUBMENU:type:data` string-encoded → typed IPC variant) | Phase 5 | Works today; not blocking | -| Drop daemon entirely (collapse into single-process UI)? | Post-2.0 | Profile first; daemon's perf justification is dubious but not measured | -| TOML reader removal timing — 2.1.0 dual-read or 3.0.0 hard cut? | Phase 3 entry | Hard cut is cleaner; one migration moment | -| LuaProvider sandboxing model — what stdlib subset can user `init.lua` access? | Phase 3 | At minimum: filesystem read, process spawn (it's a launcher), no network by default | -| Where do widgets (`weather`, `media`, `pomodoro`) live in the UI after the rewrite? | Phase 1.6 | Today they're "position: Widget" rendered above results; keep that | -| Hot-reload of `init.lua` on save? | Phase 3 | Nice-to-have; `notify` crate is already a dep | +| Widget rework: keep the "Widget position" UI concept, redesign, or drop entirely? | Post-2.0 workstream | Pulled out of Phase 1 scope per D20. Decide before re-introducing weather/media/pomodoro. | +| `owlry migrate-config` shipped in 2.x as preview, or only at 3.0.0? | Phase 3 planning | Lean toward shipping in 2.1.0 as a preview so users can test their migrations before TOML is gone. | --- From ae4a90352e2474ae393212cbbd6f4d8294dd03cc Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 01:54:33 +0200 Subject: [PATCH 03/23] refactor(v2): demolish C-ABI plugin system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the entire dynamic-loading infrastructure that produced issue #5 and the per-API-bump plugin breakage cycle: - crates/owlry-plugin-api/ (ABI-stable interface, gone) - crates/owlry-lua/ (Lua runtime cdylib, gone — replaced by mlua in Phase 3) - crates/owlry-rune/ (Rune runtime cdylib, gone per D3) - owlry-core/src/plugins/ (loader, manifest, registry, watcher — all gone) - owlry-core/src/providers/native_provider.rs - owlry-core/src/providers/lua_provider.rs - owlry-core/src/providers/config_editor.rs (per D11, 1127 LOC) - owlry/src/plugin_commands.rs (per CLI restructure, 1296 LOC) Provider trait gains submenu_actions(), execute_action(), prefix(), icon(), position(), priority() as default methods so future built-in providers can declare their UI metadata directly instead of relying on the hardcoded match table. ProviderManager simplified: drops native_providers, static_native_providers, dynamic_providers, widget_providers, runtimes, runtime_type_ids, plugin_registry fields and the reload_runtimes / find_native_provider / get_widget_item / widget_type_ids methods. ProviderPosition enum carries the Normal/ Widget distinction on the trait instead. IPC Request::PluginList and Response::PluginList removed; Submenu and PluginAction stay (route to Provider trait methods now). owlry -d / --daemon flag added as the future daemon entry point; the old owlryd binary is still produced from owlry-core for Phase 1 compatibility but will fold into the single binary in task #2. Workspace shrinks from 5 members to 2. Tests: 142 passed. LOC: -13,796 / +273 (net -13,523). Tasks #3, #4, #5 complete. --- Cargo.lock | 1042 +------------ Cargo.toml | 3 - crates/owlry-core/Cargo.toml | 14 - crates/owlry-core/src/ipc.rs | 24 +- crates/owlry-core/src/lib.rs | 1 - crates/owlry-core/src/plugins/api/action.rs | 330 ----- crates/owlry-core/src/plugins/api/cache.rs | 307 ---- crates/owlry-core/src/plugins/api/hook.rs | 418 ------ crates/owlry-core/src/plugins/api/http.rs | 350 ----- crates/owlry-core/src/plugins/api/math.rs | 196 --- crates/owlry-core/src/plugins/api/mod.rs | 77 - crates/owlry-core/src/plugins/api/process.rs | 213 --- crates/owlry-core/src/plugins/api/provider.rs | 315 ---- crates/owlry-core/src/plugins/api/theme.rs | 286 ---- crates/owlry-core/src/plugins/api/utils.rs | 569 -------- crates/owlry-core/src/plugins/error.rs | 51 - crates/owlry-core/src/plugins/loader.rs | 212 --- crates/owlry-core/src/plugins/manifest.rs | 429 ------ crates/owlry-core/src/plugins/mod.rs | 368 ----- .../owlry-core/src/plugins/native_loader.rs | 458 ------ crates/owlry-core/src/plugins/registry.rs | 292 ---- crates/owlry-core/src/plugins/runtime.rs | 154 -- .../owlry-core/src/plugins/runtime_loader.rs | 337 ----- crates/owlry-core/src/plugins/watcher.rs | 109 -- .../owlry-core/src/providers/config_editor.rs | 1127 -------------- .../owlry-core/src/providers/lua_provider.rs | 144 -- crates/owlry-core/src/providers/mod.rs | 866 +++-------- .../src/providers/native_provider.rs | 220 --- crates/owlry-core/src/server.rs | 14 - crates/owlry-lua/Cargo.toml | 51 - crates/owlry-lua/src/api/mod.rs | 57 - crates/owlry-lua/src/api/provider.rs | 271 ---- crates/owlry-lua/src/api/utils.rs | 447 ------ crates/owlry-lua/src/lib.rs | 347 ----- crates/owlry-lua/src/loader.rs | 350 ----- crates/owlry-lua/src/manifest.rs | 214 --- crates/owlry-lua/src/runtime.rs | 152 -- crates/owlry-plugin-api/Cargo.toml | 17 - crates/owlry-plugin-api/src/lib.rs | 487 ------- crates/owlry-rune/Cargo.toml | 40 - crates/owlry-rune/src/api.rs | 173 --- crates/owlry-rune/src/lib.rs | 276 ---- crates/owlry-rune/src/loader.rs | 294 ---- crates/owlry-rune/src/manifest.rs | 186 --- crates/owlry-rune/src/runtime.rs | 157 -- crates/owlry/Cargo.toml | 13 +- crates/owlry/src/app.rs | 34 +- crates/owlry/src/backend.rs | 9 +- crates/owlry/src/cli.rs | 211 +-- crates/owlry/src/client.rs | 15 +- crates/owlry/src/main.rs | 30 +- crates/owlry/src/plugin_commands.rs | 1296 ----------------- crates/owlry/src/ui/submenu.rs | 4 +- docs/RESTRUCTURE-V2.md | 12 +- 54 files changed, 273 insertions(+), 13796 deletions(-) delete mode 100644 crates/owlry-core/src/plugins/api/action.rs delete mode 100644 crates/owlry-core/src/plugins/api/cache.rs delete mode 100644 crates/owlry-core/src/plugins/api/hook.rs delete mode 100644 crates/owlry-core/src/plugins/api/http.rs delete mode 100644 crates/owlry-core/src/plugins/api/math.rs delete mode 100644 crates/owlry-core/src/plugins/api/mod.rs delete mode 100644 crates/owlry-core/src/plugins/api/process.rs delete mode 100644 crates/owlry-core/src/plugins/api/provider.rs delete mode 100644 crates/owlry-core/src/plugins/api/theme.rs delete mode 100644 crates/owlry-core/src/plugins/api/utils.rs delete mode 100644 crates/owlry-core/src/plugins/error.rs delete mode 100644 crates/owlry-core/src/plugins/loader.rs delete mode 100644 crates/owlry-core/src/plugins/manifest.rs delete mode 100644 crates/owlry-core/src/plugins/mod.rs delete mode 100644 crates/owlry-core/src/plugins/native_loader.rs delete mode 100644 crates/owlry-core/src/plugins/registry.rs delete mode 100644 crates/owlry-core/src/plugins/runtime.rs delete mode 100644 crates/owlry-core/src/plugins/runtime_loader.rs delete mode 100644 crates/owlry-core/src/plugins/watcher.rs delete mode 100644 crates/owlry-core/src/providers/config_editor.rs delete mode 100644 crates/owlry-core/src/providers/lua_provider.rs delete mode 100644 crates/owlry-core/src/providers/native_provider.rs delete mode 100644 crates/owlry-lua/Cargo.toml delete mode 100644 crates/owlry-lua/src/api/mod.rs delete mode 100644 crates/owlry-lua/src/api/provider.rs delete mode 100644 crates/owlry-lua/src/api/utils.rs delete mode 100644 crates/owlry-lua/src/lib.rs delete mode 100644 crates/owlry-lua/src/loader.rs delete mode 100644 crates/owlry-lua/src/manifest.rs delete mode 100644 crates/owlry-lua/src/runtime.rs delete mode 100644 crates/owlry-plugin-api/Cargo.toml delete mode 100644 crates/owlry-plugin-api/src/lib.rs delete mode 100644 crates/owlry-rune/Cargo.toml delete mode 100644 crates/owlry-rune/src/api.rs delete mode 100644 crates/owlry-rune/src/lib.rs delete mode 100644 crates/owlry-rune/src/loader.rs delete mode 100644 crates/owlry-rune/src/manifest.rs delete mode 100644 crates/owlry-rune/src/runtime.rs delete mode 100644 crates/owlry/src/plugin_commands.rs diff --git a/Cargo.lock b/Cargo.lock index e417914..752b379 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,66 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "abi_stable" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d6512d3eb05ffe5004c59c206de7f99c34951504056ce23fc953842f12c445" -dependencies = [ - "abi_stable_derive", - "abi_stable_shared", - "const_panic", - "core_extensions", - "crossbeam-channel", - "generational-arena", - "libloading 0.7.4", - "lock_api", - "parking_lot", - "paste", - "repr_offset", - "rustc_version", - "serde", - "serde_derive", - "serde_json", -] - -[[package]] -name = "abi_stable_derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7178468b407a4ee10e881bc7a328a65e739f0863615cca4429d43916b05e898" -dependencies = [ - "abi_stable_shared", - "as_derive_utils", - "core_extensions", - "proc-macro2", - "quote", - "rustc_version", - "syn 1.0.109", - "typed-arena", -] - -[[package]] -name = "abi_stable_shared" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2b5df7688c123e63f4d4d649cba63f2967ba7f7861b1664fca3f77d3dad2b63" -dependencies = [ - "core_extensions", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -136,18 +76,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "as_derive_utils" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff3c96645900a44cf11941c111bd08a6573b0e2f9f69bc9264b179d8fae753c4" -dependencies = [ - "core_extensions", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "async-broadcast" version = "0.7.2" @@ -241,7 +169,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -276,7 +204,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -297,12 +225,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.11.0" @@ -366,7 +288,7 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b01fe135c0bd16afe262b6dea349bd5ea30e6de50708cec639aae7c5c14cc7e4" dependencies = [ - "bitflags 2.11.0", + "bitflags", "cairo-sys-rs", "glib 0.21.5", "libc", @@ -454,7 +376,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -463,16 +385,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "codespan-reporting" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" -dependencies = [ - "termcolor", - "unicode-width 0.1.14", -] - [[package]] name = "colorchoice" version = "1.0.5" @@ -497,15 +409,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const_panic" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" -dependencies = [ - "typewit", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -522,36 +425,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core_extensions" -version = "1.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42bb5e5d0269fd4f739ea6cedaf29c16d81c27a7ce7582008e90eb50dcd57003" -dependencies = [ - "core_extensions_proc_macros", -] - -[[package]] -name = "core_extensions_proc_macros" -version = "1.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533d38ecd2709b7608fb8e18e4504deb99e9a72879e6aa66373a76d8dc4259ea" - -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -594,7 +467,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.0", + "bitflags", "objc2", ] @@ -606,15 +479,9 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - [[package]] name = "endi" version = "1.1.1" @@ -639,7 +506,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -671,17 +538,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "erased-serde" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - [[package]] name = "errno" version = "0.3.14" @@ -721,7 +577,7 @@ checksum = "da28c29743d589936ac69491e1d4596ec4bd05e5ecf9188a353fbb73dc294ccc" dependencies = [ "colored", "thiserror 2.0.18", - "unicode-width 0.2.2", + "unicode-width", ] [[package]] @@ -740,17 +596,6 @@ dependencies = [ "rustc_version", ] -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -812,15 +657,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -dependencies = [ - "libc", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -875,7 +711,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -973,30 +809,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "generational-arena" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877e94aff08e743b651baaea359664321055749b398adff8740a7399af7796e7" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "generator" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" -dependencies = [ - "cc", - "cfg-if", - "libc", - "log", - "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -1127,7 +939,7 @@ version = "0.20.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" dependencies = [ - "bitflags 2.11.0", + "bitflags", "futures-channel", "futures-core", "futures-executor", @@ -1148,7 +960,7 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" dependencies = [ - "bitflags 2.11.0", + "bitflags", "futures-channel", "futures-core", "futures-executor", @@ -1182,7 +994,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1195,7 +1007,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1321,7 +1133,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1d422cce9367945916b7a5083eedf67b0a5380d326af1943a0b5cef9afb6e48" dependencies = [ - "bitflags 2.11.0", + "bitflags", "gdk4", "glib 0.21.5", "glib-sys 0.21.5", @@ -1352,7 +1164,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1650,35 +1462,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "inotify" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" -dependencies = [ - "bitflags 1.3.2", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" -dependencies = [ - "libc", -] - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -1728,7 +1511,7 @@ checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1747,26 +1530,6 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" -[[package]] -name = "kqueue" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" -dependencies = [ - "bitflags 1.3.2", - "libc", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1785,36 +1548,13 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link 0.2.1", -] - [[package]] name = "libredox" version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags 2.11.0", "libc", - "plain", - "redox_syscall 0.7.3", ] [[package]] @@ -1842,53 +1582,12 @@ dependencies = [ "winapi", ] -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "loom" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" -dependencies = [ - "cfg-if", - "generator", - "scoped-tls", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "lua-src" -version = "550.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e836dc8ae16806c9bdcf42003a88da27d163433e3f9684c52f0301258004a4fb" -dependencies = [ - "cc", -] - -[[package]] -name = "luajit-src" -version = "210.6.6+707c12b" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a86cc925d4053d0526ae7f5bc765dbd0d7a5d1a63d43974f4966cb349ca63295" -dependencies = [ - "cc", - "which", -] - [[package]] name = "mac-notification-sys" version = "0.6.12" @@ -1910,15 +1609,6 @@ dependencies = [ "libc", ] -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - [[package]] name = "memchr" version = "2.8.0" @@ -1941,76 +1631,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "log", "wasi", "windows-sys 0.61.2", ] -[[package]] -name = "mlua" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd36acfa49ce6ee56d1307a061dd302c564eee757e6e4cd67eb4f7204846fab" -dependencies = [ - "bstr", - "either", - "erased-serde", - "libc", - "mlua-sys", - "num-traits", - "parking_lot", - "rustc-hash", - "rustversion", - "serde", - "serde-value", -] - -[[package]] -name = "mlua-sys" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f1c3a7fc7580227ece249fd90aa2fa3b39eb2b49d3aec5e103b3e85f2c3dfc8" -dependencies = [ - "cc", - "cfg-if", - "libc", - "lua-src", - "luajit-src", - "pkg-config", -] - -[[package]] -name = "musli" -version = "0.0.124" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b310b280353d9e1c92861820321f8742b02666acaf984a29cd8946965444384" -dependencies = [ - "loom", - "musli-core", - "serde", - "simdutf8", -] - -[[package]] -name = "musli-core" -version = "0.0.124" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d00e227a374e92550ce2eb5002ae116e02a43926d7243c95997138406ae4e157" -dependencies = [ - "musli-macros", -] - -[[package]] -name = "musli-macros" -version = "0.0.124" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7427c9aa85c882cd4dbe712d2fcdc511db05d595f7787e6747c90cd7d67efc4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "native-tls" version = "0.2.18" @@ -2028,37 +1652,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "notify" -version = "7.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" -dependencies = [ - "bitflags 2.11.0", - "filetime", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "walkdir", - "windows-sys 0.52.0", -] - -[[package]] -name = "notify-debouncer-mini" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaa5a66d07ed97dce782be94dcf5ab4d1b457f4243f7566c7557f15cabc8c799" -dependencies = [ - "log", - "notify", - "notify-types", - "tempfile", -] - [[package]] name = "notify-rust" version = "4.12.0" @@ -2073,94 +1666,12 @@ dependencies = [ "zbus", ] -[[package]] -name = "notify-types" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" -dependencies = [ - "instant", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -2205,7 +1716,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags", "dispatch2", "objc2", ] @@ -2222,7 +1733,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.0", + "bitflags", "block2", "libc", "objc2", @@ -2243,10 +1754,6 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -dependencies = [ - "critical-section", - "portable-atomic", -] [[package]] name = "once_cell_polyfill" @@ -2260,7 +1767,7 @@ version = "0.10.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" dependencies = [ - "bitflags 2.11.0", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -2277,7 +1784,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2304,15 +1811,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-float" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" -dependencies = [ - "num-traits", -] - [[package]] name = "ordered-stream" version = "0.2.0" @@ -2327,9 +1825,7 @@ dependencies = [ name = "owlry" version = "1.0.10" dependencies = [ - "chrono", "clap", - "dirs", "env_logger", "futures-channel", "glib-build-tools", @@ -2338,7 +1834,6 @@ dependencies = [ "libc", "log", "owlry-core", - "semver", "serde", "serde_json", "toml 0.8.23", @@ -2355,15 +1850,9 @@ dependencies = [ "freedesktop-desktop-entry", "fs2", "fuzzy-matcher", - "libloading 0.8.9", "log", - "mlua", - "notify", - "notify-debouncer-mini", "notify-rust", - "owlry-plugin-api", "reqwest", - "semver", "serde", "serde_json", "signal-hook", @@ -2372,48 +1861,6 @@ dependencies = [ "toml 0.8.23", ] -[[package]] -name = "owlry-lua" -version = "1.1.5" -dependencies = [ - "abi_stable", - "chrono", - "dirs", - "log", - "mlua", - "owlry-plugin-api", - "semver", - "serde", - "serde_json", - "tempfile", - "toml 0.8.23", -] - -[[package]] -name = "owlry-plugin-api" -version = "1.0.1" -dependencies = [ - "abi_stable", - "serde", -] - -[[package]] -name = "owlry-rune" -version = "1.1.6" -dependencies = [ - "chrono", - "dirs", - "env_logger", - "log", - "owlry-plugin-api", - "rune", - "semver", - "serde", - "serde_json", - "tempfile", - "toml 0.8.23", -] - [[package]] name = "pango" version = "0.21.5" @@ -2444,61 +1891,12 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link 0.2.1", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pin-project" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2528,12 +1926,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "polling" version = "3.11.0" @@ -2585,7 +1977,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn", ] [[package]] @@ -2630,24 +2022,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "redox_syscall" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" -dependencies = [ - "bitflags 2.11.0", -] - [[package]] name = "redox_users" version = "0.4.6" @@ -2688,15 +2062,6 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" -[[package]] -name = "repr_offset" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb1070755bd29dffc19d0971cab794e607839ba2ef4b69a9e6fbc8733c1b72ea" -dependencies = [ - "tstr", -] - [[package]] name = "reqwest" version = "0.13.2" @@ -2734,105 +2099,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "rune" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7b9174512d64882469ea9b12876305680154a541be448dcdc56f58acacbc3e0" -dependencies = [ - "anyhow", - "codespan-reporting", - "futures-core", - "futures-util", - "itoa", - "memchr", - "musli", - "num", - "once_cell", - "pin-project", - "rune-alloc", - "rune-core", - "rune-macros", - "rune-tracing", - "ryu", - "serde", - "syntree", - "unicode-ident", -] - -[[package]] -name = "rune-alloc" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12484a608c8907b9a4590f11b669263e960d1fd40f3d3c992c6f15eec931ae9" -dependencies = [ - "ahash", - "pin-project", - "rune-alloc-macros", - "serde", -] - -[[package]] -name = "rune-alloc-macros" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "382b14f6d8e65e9cfec789e85125f3e1d758b2756705739e39ccf06fd249a564" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "rune-core" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c424b28fde0f5012680361662145f238f04aeac8a320f352a6e2de863709e7b3" -dependencies = [ - "musli", - "rune-alloc", - "serde", - "twox-hash", -] - -[[package]] -name = "rune-macros" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c86600b36281adeb101c2e4f0be325752fa4c07431e9234e05be5678ad9a97f7" -dependencies = [ - "proc-macro2", - "quote", - "rune-core", - "syn 2.0.117", -] - -[[package]] -name = "rune-tracing" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717a7c726015688a19ebfa4bea03a20d2acdd96eeacb5ef48f4ec780e11ac4b" -dependencies = [ - "rune-tracing-macros", -] - -[[package]] -name = "rune-tracing-macros" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12387f96a3e131ce5be8c5668e55f1581dbc6635555d77aa07ab509fd13562bb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - [[package]] name = "rustc_version" version = "0.4.1" @@ -2848,7 +2114,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -2870,21 +2136,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - [[package]] name = "schannel" version = "0.1.29" @@ -2894,25 +2145,13 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -2945,16 +2184,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-value" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" -dependencies = [ - "ordered-float", - "serde", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -2972,7 +2201,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2996,7 +2225,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3017,15 +2246,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - [[package]] name = "shlex" version = "1.3.0" @@ -3052,12 +2272,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "slab" version = "0.4.12" @@ -3092,17 +2306,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.117" @@ -3131,15 +2334,9 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] -[[package]] -name = "syntree" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00c99c9cda412afe293a6b962af651b4594161ba88c1affe7ef66459ea040a06" - [[package]] name = "system-deps" version = "7.0.7" @@ -3190,15 +2387,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -3225,7 +2413,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3236,7 +2424,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3423,7 +2611,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.11.0", + "bitflags", "bytes", "futures-util", "http", @@ -3466,7 +2654,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3476,36 +2664,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", ] [[package]] @@ -3514,45 +2672,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tstr" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f8e0294f14baae476d0dd0a2d780b2e24d66e349a9de876f5126777a37bdba7" -dependencies = [ - "tstr_proc_macros", -] - -[[package]] -name = "tstr_proc_macros" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e78122066b0cb818b8afd08f7ed22f7fdbc3e90815035726f0840d0d26c0747a" - -[[package]] -name = "twox-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" - -[[package]] -name = "typed-arena" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" - -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] -name = "typewit" -version = "1.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71" - [[package]] name = "uds_windows" version = "1.2.1" @@ -3576,12 +2695,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - [[package]] name = "unicode-width" version = "0.2.2" @@ -3629,12 +2742,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "vcpkg" version = "0.2.15" @@ -3647,22 +2754,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "want" version = "0.3.1" @@ -3742,7 +2833,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wasm-bindgen-shared", ] @@ -3783,7 +2874,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags", "hashbrown 0.15.5", "indexmap", "semver", @@ -3799,15 +2890,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "which" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" -dependencies = [ - "libc", -] - [[package]] name = "winapi" version = "0.3.9" @@ -3824,15 +2906,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3906,7 +2979,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3917,7 +2990,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3987,15 +3060,6 @@ dependencies = [ "windows-targets 0.48.5", ] -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.59.0" @@ -4201,7 +3265,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn 2.0.117", + "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4217,7 +3281,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4229,7 +3293,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags", "indexmap", "log", "serde", @@ -4296,7 +3360,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -4344,7 +3408,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "zbus_names", "zvariant", "zvariant_utils", @@ -4361,26 +3425,6 @@ dependencies = [ "zvariant", ] -[[package]] -name = "zerocopy" -version = "0.8.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "zerofrom" version = "0.1.6" @@ -4398,7 +3442,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -4438,7 +3482,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4470,7 +3514,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "zvariant_utils", ] @@ -4483,6 +3527,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", + "syn", "winnow 0.7.15", ] diff --git a/Cargo.toml b/Cargo.toml index c961699..8010327 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,9 +3,6 @@ resolver = "2" members = [ "crates/owlry", "crates/owlry-core", - "crates/owlry-plugin-api", - "crates/owlry-lua", - "crates/owlry-rune", ] # Shared workspace settings diff --git a/crates/owlry-core/Cargo.toml b/crates/owlry-core/Cargo.toml index 1994f80..9a406f1 100644 --- a/crates/owlry-core/Cargo.toml +++ b/crates/owlry-core/Cargo.toml @@ -16,16 +16,10 @@ name = "owlryd" path = "src/main.rs" [dependencies] -owlry-plugin-api = { path = "../owlry-plugin-api" } - # Provider system fuzzy-matcher = "0.3" freedesktop-desktop-entry = "0.8" -# Plugin loading -libloading = "0.8" -semver = "1" - # Data & config serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -37,10 +31,6 @@ dirs = "5" # Error handling thiserror = "2" -# Filesystem watching (plugin hot-reload) -notify = "7" -notify-debouncer-mini = "0.5" - # Signal handling signal-hook = "0.3" @@ -53,13 +43,9 @@ notify-rust = "4" expr-solver-lib = "1" reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "blocking"] } -# Optional: embedded Lua runtime -mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true } - [dev-dependencies] tempfile = "3" [features] default = [] -lua = ["dep:mlua"] dev-logging = [] diff --git a/crates/owlry-core/src/ipc.rs b/crates/owlry-core/src/ipc.rs index 628d895..c2ab33a 100644 --- a/crates/owlry-core/src/ipc.rs +++ b/crates/owlry-core/src/ipc.rs @@ -24,8 +24,6 @@ pub enum Request { PluginAction { command: String, }, - /// Query the daemon's plugin registry (native plugins + suppressed entries). - PluginList, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -34,30 +32,10 @@ pub enum Response { Results { items: Vec }, Providers { list: Vec }, SubmenuItems { items: Vec }, - PluginList { entries: Vec }, Ack, Error { message: String }, } -/// Registry entry for a loaded or suppressed plugin (native plugins only). -/// Script plugins are tracked separately via filesystem discovery. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PluginEntry { - pub id: String, - pub name: String, - pub version: String, - /// Plugin runtime type: "native", "builtin" - pub runtime: String, - /// Load status: "active" or "suppressed" - pub status: String, - /// Human-readable detail for non-active status (e.g. suppression reason) - #[serde(default, skip_serializing_if = "String::is_empty")] - pub status_detail: String, - /// Provider type IDs registered by this plugin - #[serde(default)] - pub providers: Vec, -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ResultItem { pub id: String, @@ -72,7 +50,7 @@ pub struct ResultItem { pub terminal: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, - /// Item trust level: "core", "native_plugin", or "script_plugin". + /// Item trust level: "core" or "script_plugin". /// Defaults to "core" when absent (backwards-compatible with old daemons). #[serde(default = "default_source")] pub source: String, diff --git a/crates/owlry-core/src/lib.rs b/crates/owlry-core/src/lib.rs index 5ff1948..3ef0be7 100644 --- a/crates/owlry-core/src/lib.rs +++ b/crates/owlry-core/src/lib.rs @@ -4,6 +4,5 @@ pub mod filter; pub mod ipc; pub mod notify; pub mod paths; -pub mod plugins; pub mod providers; pub mod server; diff --git a/crates/owlry-core/src/plugins/api/action.rs b/crates/owlry-core/src/plugins/api/action.rs deleted file mode 100644 index ce798d6..0000000 --- a/crates/owlry-core/src/plugins/api/action.rs +++ /dev/null @@ -1,330 +0,0 @@ -//! Action API for Lua plugins -//! -//! Allows plugins to register custom actions for result items: -//! - `owlry.action.register(config)` - Register a custom action - -use mlua::{Function, Lua, Result as LuaResult, Table, Value}; - -/// Action registration data -#[derive(Debug, Clone)] -#[allow(dead_code)] // Used by UI integration -pub struct ActionRegistration { - /// Unique action ID - pub id: String, - /// Human-readable name shown in UI - pub display_name: String, - /// Icon name (optional) - pub icon: Option, - /// Keyboard shortcut hint (optional, e.g., "Ctrl+C") - pub shortcut: Option, - /// Plugin that registered this action - pub plugin_id: String, -} - -/// Register action APIs -pub fn register_action_api(lua: &Lua, owlry: &Table, plugin_id: &str) -> LuaResult<()> { - let action_table = lua.create_table()?; - let plugin_id_owned = plugin_id.to_string(); - - // Initialize action storage in Lua registry - if lua.named_registry_value::("actions")?.is_nil() { - let actions: Table = lua.create_table()?; - lua.set_named_registry_value("actions", actions)?; - } - - // owlry.action.register(config) -> string (action_id) - // config = { - // id = "copy-url", - // name = "Copy URL", - // icon = "edit-copy", -- optional - // shortcut = "Ctrl+C", -- optional - // filter = function(item) return item.provider == "bookmarks" end, -- optional - // handler = function(item) ... end - // } - let plugin_id_for_register = plugin_id_owned.clone(); - action_table.set( - "register", - lua.create_function(move |lua, config: Table| { - // Extract required fields - let id: String = config - .get("id") - .map_err(|_| mlua::Error::external("action.register: 'id' is required"))?; - - let name: String = config - .get("name") - .map_err(|_| mlua::Error::external("action.register: 'name' is required"))?; - - let _handler: Function = config.get("handler").map_err(|_| { - mlua::Error::external("action.register: 'handler' function is required") - })?; - - // Extract optional fields - let icon: Option = config.get("icon").ok(); - let shortcut: Option = config.get("shortcut").ok(); - - // Store action in registry - let actions: Table = lua.named_registry_value("actions")?; - - // Create full action ID with plugin prefix - let full_id = format!("{}:{}", plugin_id_for_register, id); - - // Store config with full ID - let action_entry = lua.create_table()?; - action_entry.set("id", full_id.clone())?; - action_entry.set("name", name.clone())?; - action_entry.set("plugin_id", plugin_id_for_register.clone())?; - if let Some(ref i) = icon { - action_entry.set("icon", i.clone())?; - } - if let Some(ref s) = shortcut { - action_entry.set("shortcut", s.clone())?; - } - // Store filter and handler functions - if let Ok(filter) = config.get::("filter") { - action_entry.set("filter", filter)?; - } - action_entry.set("handler", config.get::("handler")?)?; - - actions.set(full_id.clone(), action_entry)?; - - log::info!( - "[plugin:{}] Registered action '{}' ({})", - plugin_id_for_register, - name, - full_id - ); - - Ok(full_id) - })?, - )?; - - // owlry.action.unregister(id) -> boolean - let plugin_id_for_unregister = plugin_id_owned.clone(); - action_table.set( - "unregister", - lua.create_function(move |lua, id: String| { - let actions: Table = lua.named_registry_value("actions")?; - let full_id = format!("{}:{}", plugin_id_for_unregister, id); - - if actions.contains_key(full_id.clone())? { - actions.set(full_id, Value::Nil)?; - Ok(true) - } else { - Ok(false) - } - })?, - )?; - - owlry.set("action", action_table)?; - Ok(()) -} - -/// Get all registered actions from a Lua runtime -#[allow(dead_code)] // Will be used by UI -pub fn get_actions(lua: &Lua) -> LuaResult> { - let actions: Table = match lua.named_registry_value("actions") { - Ok(a) => a, - Err(_) => return Ok(Vec::new()), - }; - - let mut result = Vec::new(); - - for pair in actions.pairs::() { - let (_, entry) = pair?; - - let id: String = entry.get("id")?; - let display_name: String = entry.get("name")?; - let plugin_id: String = entry.get("plugin_id")?; - let icon: Option = entry.get("icon").ok(); - let shortcut: Option = entry.get("shortcut").ok(); - - result.push(ActionRegistration { - id, - display_name, - icon, - shortcut, - plugin_id, - }); - } - - Ok(result) -} - -/// Get actions that apply to a specific item -#[allow(dead_code)] // Will be used by UI context menu -pub fn get_actions_for_item(lua: &Lua, item: &Table) -> LuaResult> { - let actions: Table = match lua.named_registry_value("actions") { - Ok(a) => a, - Err(_) => return Ok(Vec::new()), - }; - - let mut result = Vec::new(); - - for pair in actions.pairs::() { - let (_, entry) = pair?; - - // Check filter if present - if let Ok(filter) = entry.get::("filter") { - match filter.call::(item.clone()) { - Ok(true) => {} // Include this action - Ok(false) => continue, // Skip this action - Err(e) => { - log::warn!("Action filter failed: {}", e); - continue; - } - } - } - - let id: String = entry.get("id")?; - let display_name: String = entry.get("name")?; - let plugin_id: String = entry.get("plugin_id")?; - let icon: Option = entry.get("icon").ok(); - let shortcut: Option = entry.get("shortcut").ok(); - - result.push(ActionRegistration { - id, - display_name, - icon, - shortcut, - plugin_id, - }); - } - - Ok(result) -} - -/// Execute an action by ID -#[allow(dead_code)] // Will be used by UI -pub fn execute_action(lua: &Lua, action_id: &str, item: &Table) -> LuaResult<()> { - let actions: Table = lua.named_registry_value("actions")?; - let action: Table = actions.get(action_id)?; - let handler: Function = action.get("handler")?; - - handler.call::<()>(item.clone())?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn setup_lua(plugin_id: &str) -> Lua { - let lua = Lua::new(); - let owlry = lua.create_table().unwrap(); - register_action_api(&lua, &owlry, plugin_id).unwrap(); - lua.globals().set("owlry", owlry).unwrap(); - lua - } - - #[test] - fn test_action_registration() { - let lua = setup_lua("test-plugin"); - - let chunk = lua.load( - r#" - return owlry.action.register({ - id = "copy-name", - name = "Copy Name", - icon = "edit-copy", - handler = function(item) - -- copy logic here - end - }) - "#, - ); - let action_id: String = chunk.call(()).unwrap(); - assert_eq!(action_id, "test-plugin:copy-name"); - - // Verify action is registered - let actions = get_actions(&lua).unwrap(); - assert_eq!(actions.len(), 1); - assert_eq!(actions[0].display_name, "Copy Name"); - } - - #[test] - fn test_action_with_filter() { - let lua = setup_lua("test-plugin"); - - let chunk = lua.load( - r#" - owlry.action.register({ - id = "bookmark-action", - name = "Open in Browser", - filter = function(item) - return item.provider == "bookmarks" - end, - handler = function(item) end - }) - "#, - ); - chunk.call::<()>(()).unwrap(); - - // Create bookmark item - let bookmark_item = lua.create_table().unwrap(); - bookmark_item.set("provider", "bookmarks").unwrap(); - bookmark_item.set("name", "Test Bookmark").unwrap(); - - let actions = get_actions_for_item(&lua, &bookmark_item).unwrap(); - assert_eq!(actions.len(), 1); - - // Create non-bookmark item - let app_item = lua.create_table().unwrap(); - app_item.set("provider", "applications").unwrap(); - app_item.set("name", "Test App").unwrap(); - - let actions2 = get_actions_for_item(&lua, &app_item).unwrap(); - assert_eq!(actions2.len(), 0); // Filtered out - } - - #[test] - fn test_action_unregister() { - let lua = setup_lua("test-plugin"); - - let chunk = lua.load( - r#" - owlry.action.register({ - id = "temp-action", - name = "Temporary", - handler = function(item) end - }) - return owlry.action.unregister("temp-action") - "#, - ); - let unregistered: bool = chunk.call(()).unwrap(); - assert!(unregistered); - - let actions = get_actions(&lua).unwrap(); - assert_eq!(actions.len(), 0); - } - - #[test] - fn test_execute_action() { - let lua = setup_lua("test-plugin"); - - // Register action that sets a global - let chunk = lua.load( - r#" - result = nil - owlry.action.register({ - id = "test-exec", - name = "Test Execute", - handler = function(item) - result = item.name - end - }) - "#, - ); - chunk.call::<()>(()).unwrap(); - - // Create test item - let item = lua.create_table().unwrap(); - item.set("name", "TestItem").unwrap(); - - // Execute action - execute_action(&lua, "test-plugin:test-exec", &item).unwrap(); - - // Verify handler was called - let result: String = lua.globals().get("result").unwrap(); - assert_eq!(result, "TestItem"); - } -} diff --git a/crates/owlry-core/src/plugins/api/cache.rs b/crates/owlry-core/src/plugins/api/cache.rs deleted file mode 100644 index bcb5a1f..0000000 --- a/crates/owlry-core/src/plugins/api/cache.rs +++ /dev/null @@ -1,307 +0,0 @@ -//! Cache API for Lua plugins -//! -//! Provides in-memory caching with optional TTL: -//! - `owlry.cache.get(key)` - Get cached value -//! - `owlry.cache.set(key, value, ttl_seconds?)` - Set cached value -//! - `owlry.cache.delete(key)` - Delete cached value -//! - `owlry.cache.clear()` - Clear all cached values - -use mlua::{Lua, Result as LuaResult, Table, Value}; -use std::collections::HashMap; -use std::sync::{LazyLock, Mutex}; -use std::time::{Duration, Instant}; - -/// Cached entry with optional expiration -struct CacheEntry { - value: String, // Store as JSON string for simplicity - expires_at: Option, -} - -impl CacheEntry { - fn is_expired(&self) -> bool { - self.expires_at.map(|e| Instant::now() > e).unwrap_or(false) - } -} - -/// Global cache storage (shared across all plugins) -static CACHE: LazyLock>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - -/// Register cache APIs -pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { - let cache_table = lua.create_table()?; - - // owlry.cache.get(key) -> value or nil - cache_table.set( - "get", - lua.create_function(|lua, key: String| { - let cache = CACHE - .lock() - .map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?; - - if let Some(entry) = cache.get(&key) { - if entry.is_expired() { - drop(cache); - // Remove expired entry - if let Ok(mut cache) = CACHE.lock() { - cache.remove(&key); - } - return Ok(Value::Nil); - } - - // Parse JSON back to Lua value - let json_value: serde_json::Value = - serde_json::from_str(&entry.value).map_err(|e| { - mlua::Error::external(format!("Failed to parse cached value: {}", e)) - })?; - - json_to_lua(lua, &json_value) - } else { - Ok(Value::Nil) - } - })?, - )?; - - // owlry.cache.set(key, value, ttl_seconds?) -> boolean - cache_table.set( - "set", - lua.create_function(|_lua, (key, value, ttl): (String, Value, Option)| { - let json_value = lua_value_to_json(&value)?; - let json_str = serde_json::to_string(&json_value) - .map_err(|e| mlua::Error::external(format!("Failed to serialize value: {}", e)))?; - - let expires_at = ttl.map(|secs| Instant::now() + Duration::from_secs(secs)); - - let entry = CacheEntry { - value: json_str, - expires_at, - }; - - let mut cache = CACHE - .lock() - .map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?; - - cache.insert(key, entry); - Ok(true) - })?, - )?; - - // owlry.cache.delete(key) -> boolean (true if key existed) - cache_table.set( - "delete", - lua.create_function(|_lua, key: String| { - let mut cache = CACHE - .lock() - .map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?; - - Ok(cache.remove(&key).is_some()) - })?, - )?; - - // owlry.cache.clear() -> number of entries removed - cache_table.set( - "clear", - lua.create_function(|_lua, ()| { - let mut cache = CACHE - .lock() - .map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?; - - let count = cache.len(); - cache.clear(); - Ok(count) - })?, - )?; - - // owlry.cache.has(key) -> boolean - cache_table.set( - "has", - lua.create_function(|_lua, key: String| { - let cache = CACHE - .lock() - .map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?; - - if let Some(entry) = cache.get(&key) { - Ok(!entry.is_expired()) - } else { - Ok(false) - } - })?, - )?; - - owlry.set("cache", cache_table)?; - Ok(()) -} - -/// Convert Lua value to serde_json::Value -fn lua_value_to_json(value: &Value) -> LuaResult { - use serde_json::Value as JsonValue; - - match value { - Value::Nil => Ok(JsonValue::Null), - Value::Boolean(b) => Ok(JsonValue::Bool(*b)), - Value::Integer(i) => Ok(JsonValue::Number((*i).into())), - Value::Number(n) => Ok(serde_json::Number::from_f64(*n) - .map(JsonValue::Number) - .unwrap_or(JsonValue::Null)), - Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())), - Value::Table(t) => lua_table_to_json(t), - _ => Err(mlua::Error::external("Unsupported Lua type for cache")), - } -} - -/// Convert Lua table to serde_json::Value -fn lua_table_to_json(table: &Table) -> LuaResult { - use serde_json::{Map, Value as JsonValue}; - - // Check if it's an array (sequential integer keys starting from 1) - let is_array = table - .clone() - .pairs::() - .enumerate() - .all(|(i, pair)| pair.map(|(k, _)| k == (i + 1) as i64).unwrap_or(false)); - - if is_array { - let mut arr = Vec::new(); - for pair in table.clone().pairs::() { - let (_, v) = pair?; - arr.push(lua_value_to_json(&v)?); - } - Ok(JsonValue::Array(arr)) - } else { - let mut map = Map::new(); - for pair in table.clone().pairs::() { - let (k, v) = pair?; - map.insert(k, lua_value_to_json(&v)?); - } - Ok(JsonValue::Object(map)) - } -} - -/// Convert serde_json::Value to Lua value -fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult { - use serde_json::Value as JsonValue; - - match value { - JsonValue::Null => Ok(Value::Nil), - JsonValue::Bool(b) => Ok(Value::Boolean(*b)), - JsonValue::Number(n) => { - if let Some(i) = n.as_i64() { - Ok(Value::Integer(i)) - } else if let Some(f) = n.as_f64() { - Ok(Value::Number(f)) - } else { - Ok(Value::Nil) - } - } - JsonValue::String(s) => Ok(Value::String(lua.create_string(s)?)), - JsonValue::Array(arr) => { - let table = lua.create_table()?; - for (i, v) in arr.iter().enumerate() { - table.set(i + 1, json_to_lua(lua, v)?)?; - } - Ok(Value::Table(table)) - } - JsonValue::Object(obj) => { - let table = lua.create_table()?; - for (k, v) in obj { - table.set(k.as_str(), json_to_lua(lua, v)?)?; - } - Ok(Value::Table(table)) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn setup_lua() -> Lua { - let lua = Lua::new(); - let owlry = lua.create_table().unwrap(); - register_cache_api(&lua, &owlry).unwrap(); - lua.globals().set("owlry", owlry).unwrap(); - - // Clear cache between tests - CACHE.lock().unwrap().clear(); - - lua - } - - #[test] - fn test_cache_set_get() { - let lua = setup_lua(); - - // Set a value - let chunk = lua.load(r#"return owlry.cache.set("test_key", "test_value")"#); - let result: bool = chunk.call(()).unwrap(); - assert!(result); - - // Get the value back - let chunk = lua.load(r#"return owlry.cache.get("test_key")"#); - let value: String = chunk.call(()).unwrap(); - assert_eq!(value, "test_value"); - } - - #[test] - fn test_cache_table_value() { - let lua = setup_lua(); - - // Set a table value - let chunk = lua.load(r#"return owlry.cache.set("table_key", {name = "test", value = 42})"#); - let _: bool = chunk.call(()).unwrap(); - - // Get and verify - let chunk = lua.load( - r#" - local t = owlry.cache.get("table_key") - return t.name, t.value - "#, - ); - let (name, value): (String, i32) = chunk.call(()).unwrap(); - assert_eq!(name, "test"); - assert_eq!(value, 42); - } - - #[test] - fn test_cache_delete() { - let lua = setup_lua(); - - let chunk = lua.load( - r#" - owlry.cache.set("delete_key", "value") - local existed = owlry.cache.delete("delete_key") - local value = owlry.cache.get("delete_key") - return existed, value - "#, - ); - let (existed, value): (bool, Option) = chunk.call(()).unwrap(); - assert!(existed); - assert!(value.is_none()); - } - - #[test] - fn test_cache_has() { - let lua = setup_lua(); - - let chunk = lua.load( - r#" - local before = owlry.cache.has("has_key") - owlry.cache.set("has_key", "value") - local after = owlry.cache.has("has_key") - return before, after - "#, - ); - let (before, after): (bool, bool) = chunk.call(()).unwrap(); - assert!(!before); - assert!(after); - } - - #[test] - fn test_cache_missing_key() { - let lua = setup_lua(); - - let chunk = lua.load(r#"return owlry.cache.get("nonexistent_key")"#); - let value: Value = chunk.call(()).unwrap(); - assert!(matches!(value, Value::Nil)); - } -} diff --git a/crates/owlry-core/src/plugins/api/hook.rs b/crates/owlry-core/src/plugins/api/hook.rs deleted file mode 100644 index 067db61..0000000 --- a/crates/owlry-core/src/plugins/api/hook.rs +++ /dev/null @@ -1,418 +0,0 @@ -//! Hook API for Lua plugins -//! -//! Allows plugins to register callbacks for application events: -//! - `owlry.hook.on(event, callback)` - Register a hook -//! - Events: init, query, results, select, pre_launch, post_launch, shutdown - -use mlua::{Function, Lua, Result as LuaResult, Table, Value}; -use std::collections::HashMap; -use std::sync::{LazyLock, Mutex}; - -/// Hook event types -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum HookEvent { - /// Called when plugin is initialized - Init, - /// Called when query changes, can modify query - Query, - /// Called after results are gathered, can filter/modify results - Results, - /// Called when an item is selected (highlighted) - Select, - /// Called before launching an item, can cancel launch - PreLaunch, - /// Called after launching an item - PostLaunch, - /// Called when application is shutting down - Shutdown, -} - -impl HookEvent { - fn from_str(s: &str) -> Option { - match s.to_lowercase().as_str() { - "init" => Some(Self::Init), - "query" => Some(Self::Query), - "results" => Some(Self::Results), - "select" => Some(Self::Select), - "pre_launch" | "prelaunch" => Some(Self::PreLaunch), - "post_launch" | "postlaunch" => Some(Self::PostLaunch), - "shutdown" => Some(Self::Shutdown), - _ => None, - } - } - - fn as_str(&self) -> &'static str { - match self { - Self::Init => "init", - Self::Query => "query", - Self::Results => "results", - Self::Select => "select", - Self::PreLaunch => "pre_launch", - Self::PostLaunch => "post_launch", - Self::Shutdown => "shutdown", - } - } -} - -/// Registered hook information -#[derive(Debug, Clone)] -#[allow(dead_code)] // Will be used for hook inspection -pub struct HookRegistration { - pub event: HookEvent, - pub plugin_id: String, - pub priority: i32, -} - -/// Type alias for hook handlers: (plugin_id, priority) -type HookHandlers = Vec<(String, i32)>; - -/// Global hook registry -/// Maps event -> list of (plugin_id, priority) -static HOOK_REGISTRY: LazyLock>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - -/// Register hook APIs -pub fn register_hook_api(lua: &Lua, owlry: &Table, plugin_id: &str) -> LuaResult<()> { - let hook_table = lua.create_table()?; - let plugin_id_owned = plugin_id.to_string(); - - // Store plugin_id in registry for later use - lua.set_named_registry_value("plugin_id", plugin_id_owned.clone())?; - - // Initialize hook storage in Lua registry - if lua.named_registry_value::("hooks")?.is_nil() { - let hooks: Table = lua.create_table()?; - lua.set_named_registry_value("hooks", hooks)?; - } - - // owlry.hook.on(event, callback, priority?) -> boolean - // Register a hook for an event - let plugin_id_for_closure = plugin_id_owned.clone(); - hook_table.set( - "on", - lua.create_function(move |lua, (event_name, callback, priority): (String, Function, Option)| { - let event = HookEvent::from_str(&event_name).ok_or_else(|| { - mlua::Error::external(format!( - "Unknown hook event '{}'. Valid events: init, query, results, select, pre_launch, post_launch, shutdown", - event_name - )) - })?; - - let priority = priority.unwrap_or(0); - - // Store callback in Lua registry - let hooks: Table = lua.named_registry_value("hooks")?; - let event_key = event.as_str(); - - let event_hooks: Table = if let Ok(t) = hooks.get::(event_key) { - t - } else { - let t = lua.create_table()?; - hooks.set(event_key, t.clone())?; - t - }; - - // Add callback to event hooks - let len = event_hooks.len()? + 1; - let hook_entry = lua.create_table()?; - hook_entry.set("callback", callback)?; - hook_entry.set("priority", priority)?; - event_hooks.set(len, hook_entry)?; - - // Register in global registry - let mut registry = HOOK_REGISTRY.lock().map_err(|e| { - mlua::Error::external(format!("Failed to lock hook registry: {}", e)) - })?; - - let hooks_list = registry.entry(event).or_insert_with(Vec::new); - hooks_list.push((plugin_id_for_closure.clone(), priority)); - // Sort by priority (higher priority first) - hooks_list.sort_by(|a, b| b.1.cmp(&a.1)); - - log::debug!( - "[plugin:{}] Registered hook for '{}' with priority {}", - plugin_id_for_closure, - event_name, - priority - ); - - Ok(true) - })?, - )?; - - // owlry.hook.off(event) -> boolean - // Unregister all hooks for an event from this plugin - let plugin_id_for_off = plugin_id_owned.clone(); - hook_table.set( - "off", - lua.create_function(move |lua, event_name: String| { - let event = HookEvent::from_str(&event_name).ok_or_else(|| { - mlua::Error::external(format!("Unknown hook event '{}'", event_name)) - })?; - - // Remove from Lua registry - let hooks: Table = lua.named_registry_value("hooks")?; - hooks.set(event.as_str(), Value::Nil)?; - - // Remove from global registry - let mut registry = HOOK_REGISTRY.lock().map_err(|e| { - mlua::Error::external(format!("Failed to lock hook registry: {}", e)) - })?; - - if let Some(hooks_list) = registry.get_mut(&event) { - hooks_list.retain(|(id, _)| id != &plugin_id_for_off); - } - - log::debug!( - "[plugin:{}] Unregistered hooks for '{}'", - plugin_id_for_off, - event_name - ); - - Ok(true) - })?, - )?; - - owlry.set("hook", hook_table)?; - Ok(()) -} - -/// Call hooks for a specific event in a Lua runtime -/// Returns the (possibly modified) value -#[allow(dead_code)] // Will be used by UI integration -pub fn call_hooks(lua: &Lua, event: HookEvent, value: T) -> LuaResult -where - T: mlua::IntoLua + mlua::FromLua, -{ - let hooks: Table = match lua.named_registry_value("hooks") { - Ok(h) => h, - Err(_) => return Ok(value), // No hooks registered - }; - - let event_hooks: Table = match hooks.get(event.as_str()) { - Ok(h) => h, - Err(_) => return Ok(value), // No hooks for this event - }; - - let mut current_value = value.into_lua(lua)?; - - // Collect hooks with priorities - let mut hook_entries: Vec<(i32, Function)> = Vec::new(); - for pair in event_hooks.pairs::() { - let (_, entry) = pair?; - let priority: i32 = entry.get("priority").unwrap_or(0); - let callback: Function = entry.get("callback")?; - hook_entries.push((priority, callback)); - } - - // Sort by priority (higher first) - hook_entries.sort_by(|a, b| b.0.cmp(&a.0)); - - // Call each hook - for (_, callback) in hook_entries { - match callback.call::(current_value.clone()) { - Ok(result) => { - // If hook returns non-nil, use it as the new value - if !result.is_nil() { - current_value = result; - } - } - Err(e) => { - log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e); - // Continue with other hooks - } - } - } - - T::from_lua(current_value, lua) -} - -/// Call hooks that return a boolean (for pre_launch cancellation) -#[allow(dead_code)] // Will be used for pre_launch hooks -pub fn call_hooks_bool(lua: &Lua, event: HookEvent, value: Value) -> LuaResult { - let hooks: Table = match lua.named_registry_value("hooks") { - Ok(h) => h, - Err(_) => return Ok(true), // No hooks, allow - }; - - let event_hooks: Table = match hooks.get(event.as_str()) { - Ok(h) => h, - Err(_) => return Ok(true), // No hooks for this event - }; - - // Collect and sort hooks - let mut hook_entries: Vec<(i32, Function)> = Vec::new(); - for pair in event_hooks.pairs::() { - let (_, entry) = pair?; - let priority: i32 = entry.get("priority").unwrap_or(0); - let callback: Function = entry.get("callback")?; - hook_entries.push((priority, callback)); - } - hook_entries.sort_by(|a, b| b.0.cmp(&a.0)); - - // Call each hook - if any returns false, cancel - for (_, callback) in hook_entries { - match callback.call::(value.clone()) { - Ok(result) => { - if let Value::Boolean(false) = result { - return Ok(false); // Cancel - } - } - Err(e) => { - log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e); - } - } - } - - Ok(true) -} - -/// Call hooks with no return value (for notifications) -#[allow(dead_code)] // Will be used for notification hooks -pub fn call_hooks_void(lua: &Lua, event: HookEvent, value: Value) -> LuaResult<()> { - let hooks: Table = match lua.named_registry_value("hooks") { - Ok(h) => h, - Err(_) => return Ok(()), // No hooks - }; - - let event_hooks: Table = match hooks.get(event.as_str()) { - Ok(h) => h, - Err(_) => return Ok(()), // No hooks for this event - }; - - for pair in event_hooks.pairs::() { - let (_, entry) = pair?; - let callback: Function = entry.get("callback")?; - if let Err(e) = callback.call::<()>(value.clone()) { - log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e); - } - } - - Ok(()) -} - -/// Get list of plugins that have registered for an event -#[allow(dead_code)] -pub fn get_registered_plugins(event: HookEvent) -> Vec { - HOOK_REGISTRY - .lock() - .map(|r| { - r.get(&event) - .map(|v| v.iter().map(|(id, _)| id.clone()).collect()) - .unwrap_or_default() - }) - .unwrap_or_default() -} - -/// Clear all hooks (used when reloading plugins) -#[allow(dead_code)] -pub fn clear_all_hooks() { - if let Ok(mut registry) = HOOK_REGISTRY.lock() { - registry.clear(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn setup_lua(plugin_id: &str) -> Lua { - let lua = Lua::new(); - let owlry = lua.create_table().unwrap(); - register_hook_api(&lua, &owlry, plugin_id).unwrap(); - lua.globals().set("owlry", owlry).unwrap(); - lua - } - - #[test] - fn test_hook_registration() { - clear_all_hooks(); - let lua = setup_lua("test-plugin"); - - let chunk = lua.load( - r#" - local called = false - owlry.hook.on("init", function() - called = true - end) - return true - "#, - ); - let result: bool = chunk.call(()).unwrap(); - assert!(result); - - // Verify hook was registered - let plugins = get_registered_plugins(HookEvent::Init); - assert!(plugins.contains(&"test-plugin".to_string())); - } - - #[test] - fn test_hook_with_priority() { - clear_all_hooks(); - let lua = setup_lua("test-plugin"); - - let chunk = lua.load( - r#" - owlry.hook.on("query", function(q) return q .. "1" end, 10) - owlry.hook.on("query", function(q) return q .. "2" end, 20) - return true - "#, - ); - chunk.call::<()>(()).unwrap(); - - // Call hooks - higher priority (20) should run first - let result: String = call_hooks(&lua, HookEvent::Query, "test".to_string()).unwrap(); - // Priority 20 adds "2" first, then priority 10 adds "1" - assert_eq!(result, "test21"); - } - - #[test] - fn test_hook_off() { - clear_all_hooks(); - let lua = setup_lua("test-plugin"); - - let chunk = lua.load( - r#" - owlry.hook.on("select", function() end) - owlry.hook.off("select") - return true - "#, - ); - chunk.call::<()>(()).unwrap(); - - let plugins = get_registered_plugins(HookEvent::Select); - assert!(!plugins.contains(&"test-plugin".to_string())); - } - - #[test] - fn test_pre_launch_cancel() { - clear_all_hooks(); - let lua = setup_lua("test-plugin"); - - let chunk = lua.load( - r#" - owlry.hook.on("pre_launch", function(item) - if item.name == "blocked" then - return false -- cancel launch - end - return true - end) - "#, - ); - chunk.call::<()>(()).unwrap(); - - // Create a test item table - let item = lua.create_table().unwrap(); - item.set("name", "blocked").unwrap(); - - let allow = call_hooks_bool(&lua, HookEvent::PreLaunch, Value::Table(item)).unwrap(); - assert!(!allow); // Should be blocked - - // Test with allowed item - let item2 = lua.create_table().unwrap(); - item2.set("name", "allowed").unwrap(); - - let allow2 = call_hooks_bool(&lua, HookEvent::PreLaunch, Value::Table(item2)).unwrap(); - assert!(allow2); // Should be allowed - } -} diff --git a/crates/owlry-core/src/plugins/api/http.rs b/crates/owlry-core/src/plugins/api/http.rs deleted file mode 100644 index 1012b49..0000000 --- a/crates/owlry-core/src/plugins/api/http.rs +++ /dev/null @@ -1,350 +0,0 @@ -//! HTTP client API for Lua plugins -//! -//! Provides: -//! - `owlry.http.get(url, opts)` - HTTP GET request -//! - `owlry.http.post(url, body, opts)` - HTTP POST request - -use mlua::{Lua, Result as LuaResult, Table, Value}; -use std::collections::HashMap; -use std::time::Duration; - -/// Register HTTP client APIs -pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { - let http_table = lua.create_table()?; - - // owlry.http.get(url, opts?) -> { status, body, headers } - http_table.set( - "get", - lua.create_function(|lua, (url, opts): (String, Option
)| { - log::debug!("[plugin] http.get: {}", url); - - let timeout_secs = opts - .as_ref() - .and_then(|o| o.get::("timeout").ok()) - .unwrap_or(30); - - let client = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(timeout_secs)) - .build() - .map_err(|e| { - mlua::Error::external(format!("Failed to create HTTP client: {}", e)) - })?; - - let mut request = client.get(&url); - - // Add custom headers if provided - if let Some(ref opts) = opts - && let Ok(headers) = opts.get::
("headers") - { - for pair in headers.pairs::() { - let (key, value) = pair?; - request = request.header(&key, &value); - } - } - - let response = request - .send() - .map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?; - - let status = response.status().as_u16(); - let headers = extract_headers(&response); - let body = response.text().map_err(|e| { - mlua::Error::external(format!("Failed to read response body: {}", e)) - })?; - - let result = lua.create_table()?; - result.set("status", status)?; - result.set("body", body)?; - result.set("ok", (200..300).contains(&status))?; - - let headers_table = lua.create_table()?; - for (key, value) in headers { - headers_table.set(key, value)?; - } - result.set("headers", headers_table)?; - - Ok(result) - })?, - )?; - - // owlry.http.post(url, body, opts?) -> { status, body, headers } - http_table.set( - "post", - lua.create_function(|lua, (url, body, opts): (String, Value, Option
)| { - log::debug!("[plugin] http.post: {}", url); - - let timeout_secs = opts - .as_ref() - .and_then(|o| o.get::("timeout").ok()) - .unwrap_or(30); - - let client = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(timeout_secs)) - .build() - .map_err(|e| { - mlua::Error::external(format!("Failed to create HTTP client: {}", e)) - })?; - - let mut request = client.post(&url); - - // Add custom headers if provided - if let Some(ref opts) = opts - && let Ok(headers) = opts.get::
("headers") - { - for pair in headers.pairs::() { - let (key, value) = pair?; - request = request.header(&key, &value); - } - } - - // Set body based on type - request = match body { - Value::String(s) => request.body(s.to_str()?.to_string()), - Value::Table(t) => { - // Assume JSON if body is a table - let json_str = table_to_json(&t)?; - request - .header("Content-Type", "application/json") - .body(json_str) - } - Value::Nil => request, - _ => return Err(mlua::Error::external("POST body must be a string or table")), - }; - - let response = request - .send() - .map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?; - - let status = response.status().as_u16(); - let headers = extract_headers(&response); - let body = response.text().map_err(|e| { - mlua::Error::external(format!("Failed to read response body: {}", e)) - })?; - - let result = lua.create_table()?; - result.set("status", status)?; - result.set("body", body)?; - result.set("ok", (200..300).contains(&status))?; - - let headers_table = lua.create_table()?; - for (key, value) in headers { - headers_table.set(key, value)?; - } - result.set("headers", headers_table)?; - - Ok(result) - })?, - )?; - - // owlry.http.get_json(url, opts?) -> parsed JSON as table - // Convenience function that parses JSON response - http_table.set( - "get_json", - lua.create_function(|lua, (url, opts): (String, Option
)| { - log::debug!("[plugin] http.get_json: {}", url); - - let timeout_secs = opts - .as_ref() - .and_then(|o| o.get::("timeout").ok()) - .unwrap_or(30); - - let client = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(timeout_secs)) - .build() - .map_err(|e| { - mlua::Error::external(format!("Failed to create HTTP client: {}", e)) - })?; - - let mut request = client.get(&url); - request = request.header("Accept", "application/json"); - - // Add custom headers if provided - if let Some(ref opts) = opts - && let Ok(headers) = opts.get::
("headers") - { - for pair in headers.pairs::() { - let (key, value) = pair?; - request = request.header(&key, &value); - } - } - - let response = request - .send() - .map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?; - - if !response.status().is_success() { - return Err(mlua::Error::external(format!( - "HTTP request failed with status {}", - response.status() - ))); - } - - let body = response.text().map_err(|e| { - mlua::Error::external(format!("Failed to read response body: {}", e)) - })?; - - // Parse JSON and convert to Lua table - let json_value: serde_json::Value = serde_json::from_str(&body) - .map_err(|e| mlua::Error::external(format!("Failed to parse JSON: {}", e)))?; - - json_to_lua(lua, &json_value) - })?, - )?; - - owlry.set("http", http_table)?; - Ok(()) -} - -/// Extract headers from response into a HashMap -fn extract_headers(response: &reqwest::blocking::Response) -> HashMap { - response - .headers() - .iter() - .filter_map(|(k, v)| { - v.to_str() - .ok() - .map(|v| (k.as_str().to_lowercase(), v.to_string())) - }) - .collect() -} - -/// Convert a Lua table to JSON string -fn table_to_json(table: &Table) -> LuaResult { - let value = lua_to_json(table)?; - serde_json::to_string(&value) - .map_err(|e| mlua::Error::external(format!("Failed to serialize to JSON: {}", e))) -} - -/// Convert Lua table to serde_json::Value -fn lua_to_json(table: &Table) -> LuaResult { - use serde_json::{Map, Value as JsonValue}; - - // Check if it's an array (sequential integer keys starting from 1) - let is_array = table - .clone() - .pairs::() - .enumerate() - .all(|(i, pair)| pair.map(|(k, _)| k == (i + 1) as i64).unwrap_or(false)); - - if is_array { - let mut arr = Vec::new(); - for pair in table.clone().pairs::() { - let (_, v) = pair?; - arr.push(lua_value_to_json(&v)?); - } - Ok(JsonValue::Array(arr)) - } else { - let mut map = Map::new(); - for pair in table.clone().pairs::() { - let (k, v) = pair?; - map.insert(k, lua_value_to_json(&v)?); - } - Ok(JsonValue::Object(map)) - } -} - -/// Convert a single Lua value to JSON -fn lua_value_to_json(value: &Value) -> LuaResult { - use serde_json::Value as JsonValue; - - match value { - Value::Nil => Ok(JsonValue::Null), - Value::Boolean(b) => Ok(JsonValue::Bool(*b)), - Value::Integer(i) => Ok(JsonValue::Number((*i).into())), - Value::Number(n) => Ok(serde_json::Number::from_f64(*n) - .map(JsonValue::Number) - .unwrap_or(JsonValue::Null)), - Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())), - Value::Table(t) => lua_to_json(t), - _ => Err(mlua::Error::external("Unsupported Lua type for JSON")), - } -} - -/// Convert serde_json::Value to Lua value -fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult { - use serde_json::Value as JsonValue; - - match value { - JsonValue::Null => Ok(Value::Nil), - JsonValue::Bool(b) => Ok(Value::Boolean(*b)), - JsonValue::Number(n) => { - if let Some(i) = n.as_i64() { - Ok(Value::Integer(i)) - } else if let Some(f) = n.as_f64() { - Ok(Value::Number(f)) - } else { - Ok(Value::Nil) - } - } - JsonValue::String(s) => Ok(Value::String(lua.create_string(s)?)), - JsonValue::Array(arr) => { - let table = lua.create_table()?; - for (i, v) in arr.iter().enumerate() { - table.set(i + 1, json_to_lua(lua, v)?)?; - } - Ok(Value::Table(table)) - } - JsonValue::Object(obj) => { - let table = lua.create_table()?; - for (k, v) in obj { - table.set(k.as_str(), json_to_lua(lua, v)?)?; - } - Ok(Value::Table(table)) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn setup_lua() -> Lua { - let lua = Lua::new(); - let owlry = lua.create_table().unwrap(); - register_http_api(&lua, &owlry).unwrap(); - lua.globals().set("owlry", owlry).unwrap(); - lua - } - - #[test] - fn test_json_conversion() { - let lua = setup_lua(); - - // Test table to JSON - let table = lua.create_table().unwrap(); - table.set("name", "test").unwrap(); - table.set("value", 42).unwrap(); - - let json = table_to_json(&table).unwrap(); - assert!(json.contains("name")); - assert!(json.contains("test")); - assert!(json.contains("42")); - } - - #[test] - fn test_array_to_json() { - let lua = setup_lua(); - - let table = lua.create_table().unwrap(); - table.set(1, "first").unwrap(); - table.set(2, "second").unwrap(); - table.set(3, "third").unwrap(); - - let json = table_to_json(&table).unwrap(); - assert!(json.starts_with('[')); - assert!(json.contains("first")); - } - - // Note: Network tests are skipped in CI - they require internet access - // Use `cargo test -- --ignored` to run them locally - #[test] - #[ignore] - fn test_http_get() { - let lua = setup_lua(); - let chunk = lua.load(r#"return owlry.http.get("https://httpbin.org/get")"#); - let result: Table = chunk.call(()).unwrap(); - - assert_eq!(result.get::("status").unwrap(), 200); - assert!(result.get::("ok").unwrap()); - } -} diff --git a/crates/owlry-core/src/plugins/api/math.rs b/crates/owlry-core/src/plugins/api/math.rs deleted file mode 100644 index 8558708..0000000 --- a/crates/owlry-core/src/plugins/api/math.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! Math calculation API for Lua plugins -//! -//! Provides safe math expression evaluation: -//! - `owlry.math.calculate(expression)` - Evaluate a math expression - -use expr_solver::{SymTable, eval_with_table}; -use mlua::{Lua, Result as LuaResult, Table}; - -fn eval_math(expr: &str) -> Result { - let mut table = SymTable::stdlib(); - table - .add_func("ln", 1, false, |args| Ok(args[0].ln()), false) - .expect("ln alias is valid"); - eval_with_table(expr, table) -} - -/// Register math APIs -pub fn register_math_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { - let math_table = lua.create_table()?; - - // owlry.math.calculate(expression) -> number or nil, error - // Evaluates a mathematical expression safely - // Returns (result, nil) on success or (nil, error_message) on failure - math_table.set( - "calculate", - lua.create_function( - |_lua, expr: String| -> LuaResult<(Option, Option)> { - match eval_math(&expr) { - Ok(result) => { - if result.is_finite() { - Ok((Some(result), None)) - } else { - Ok((None, Some("Result is not a finite number".to_string()))) - } - } - Err(e) => Ok((None, Some(e))), - } - }, - )?, - )?; - - // owlry.math.calc(expression) -> number (throws on error) - // Convenience function that throws instead of returning error - math_table.set( - "calc", - lua.create_function(|_lua, expr: String| { - eval_math(&expr) - .map_err(|e| mlua::Error::external(format!("Math error: {}", e))) - .and_then(|r| { - if r.is_finite() { - Ok(r) - } else { - Err(mlua::Error::external("Result is not a finite number")) - } - }) - })?, - )?; - - // owlry.math.is_expression(str) -> boolean - // Check if a string looks like a math expression - math_table.set( - "is_expression", - lua.create_function(|_lua, expr: String| { - let trimmed = expr.trim(); - - // Must have at least one digit - if !trimmed.chars().any(|c| c.is_ascii_digit()) { - return Ok(false); - } - - // Should only contain valid math characters - let valid = trimmed.chars().all(|c| { - c.is_ascii_digit() - || c.is_ascii_alphabetic() - || matches!(c, '+' | '-' | '*' | '/' | '^' | '(' | ')' | '.' | ' ' | '%') - }); - - Ok(valid) - })?, - )?; - - // owlry.math.format(number, decimals?) -> string - // Format a number with optional decimal places - math_table.set( - "format", - lua.create_function(|_lua, (num, decimals): (f64, Option)| { - let decimals = decimals.unwrap_or(2); - - // Check if it's effectively an integer - if (num - num.round()).abs() < f64::EPSILON { - Ok(format!("{}", num as i64)) - } else { - Ok(format!("{:.prec$}", num, prec = decimals)) - } - })?, - )?; - - owlry.set("math", math_table)?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn setup_lua() -> Lua { - let lua = Lua::new(); - let owlry = lua.create_table().unwrap(); - register_math_api(&lua, &owlry).unwrap(); - lua.globals().set("owlry", owlry).unwrap(); - lua - } - - #[test] - fn test_calculate_basic() { - let lua = setup_lua(); - - let chunk = lua.load( - r#" - local result, err = owlry.math.calculate("2 + 2") - if err then error(err) end - return result - "#, - ); - let result: f64 = chunk.call(()).unwrap(); - assert!((result - 4.0).abs() < f64::EPSILON); - } - - #[test] - fn test_calculate_complex() { - let lua = setup_lua(); - - let chunk = lua.load( - r#" - local result, err = owlry.math.calculate("sqrt(16) + 2^3") - if err then error(err) end - return result - "#, - ); - let result: f64 = chunk.call(()).unwrap(); - assert!((result - 12.0).abs() < f64::EPSILON); // sqrt(16) = 4, 2^3 = 8 - } - - #[test] - fn test_calculate_error() { - let lua = setup_lua(); - - let chunk = lua.load( - r#" - local result, err = owlry.math.calculate("invalid expression @@") - if result then - return false -- should not succeed - else - return true -- correctly failed - end - "#, - ); - let had_error: bool = chunk.call(()).unwrap(); - assert!(had_error); - } - - #[test] - fn test_calc_throws() { - let lua = setup_lua(); - - let chunk = lua.load(r#"return owlry.math.calc("3 * 4")"#); - let result: f64 = chunk.call(()).unwrap(); - assert!((result - 12.0).abs() < f64::EPSILON); - } - - #[test] - fn test_is_expression() { - let lua = setup_lua(); - - let chunk = lua.load(r#"return owlry.math.is_expression("2 + 2")"#); - let is_expr: bool = chunk.call(()).unwrap(); - assert!(is_expr); - - let chunk = lua.load(r#"return owlry.math.is_expression("hello world")"#); - let is_expr: bool = chunk.call(()).unwrap(); - assert!(!is_expr); - } - - #[test] - fn test_format() { - let lua = setup_lua(); - - let chunk = lua.load(r#"return owlry.math.format(3.14159, 2)"#); - let formatted: String = chunk.call(()).unwrap(); - assert_eq!(formatted, "3.14"); - - let chunk = lua.load(r#"return owlry.math.format(42.0)"#); - let formatted: String = chunk.call(()).unwrap(); - assert_eq!(formatted, "42"); - } -} diff --git a/crates/owlry-core/src/plugins/api/mod.rs b/crates/owlry-core/src/plugins/api/mod.rs deleted file mode 100644 index 10fa1ef..0000000 --- a/crates/owlry-core/src/plugins/api/mod.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! Lua API implementations for plugins -//! -//! This module provides the `owlry` global table and its submodules -//! that plugins can use to interact with owlry. - -pub mod action; -mod cache; -pub mod hook; -mod http; -mod math; -mod process; -pub mod provider; -pub mod theme; -mod utils; - -use mlua::{Lua, Result as LuaResult}; - -pub use action::ActionRegistration; -pub use hook::HookEvent; -pub use provider::ProviderRegistration; -pub use theme::ThemeRegistration; - -/// Register all owlry APIs in the Lua runtime -/// -/// This creates the `owlry` global table with all available APIs: -/// - `owlry.log.*` - Logging functions -/// - `owlry.path.*` - XDG path helpers -/// - `owlry.fs.*` - Filesystem operations -/// - `owlry.json.*` - JSON encode/decode -/// - `owlry.provider.*` - Provider registration -/// - `owlry.process.*` - Process execution -/// - `owlry.env.*` - Environment variables -/// - `owlry.http.*` - HTTP client -/// - `owlry.cache.*` - In-memory caching -/// - `owlry.math.*` - Math expression evaluation -/// - `owlry.hook.*` - Event hooks -/// - `owlry.action.*` - Custom actions -/// - `owlry.theme.*` - Theme registration -pub fn register_apis(lua: &Lua, plugin_dir: &std::path::Path, plugin_id: &str) -> LuaResult<()> { - let globals = lua.globals(); - - // Create the main owlry table - let owlry = lua.create_table()?; - - // Register utility APIs (log, path, fs, json) - utils::register_log_api(lua, &owlry)?; - utils::register_path_api(lua, &owlry, plugin_dir)?; - utils::register_fs_api(lua, &owlry, plugin_dir)?; - utils::register_json_api(lua, &owlry)?; - - // Register provider API - provider::register_provider_api(lua, &owlry)?; - - // Register extended APIs (Phase 3) - process::register_process_api(lua, &owlry)?; - process::register_env_api(lua, &owlry)?; - http::register_http_api(lua, &owlry)?; - cache::register_cache_api(lua, &owlry)?; - math::register_math_api(lua, &owlry)?; - - // Register Phase 4 APIs (hooks, actions, themes) - hook::register_hook_api(lua, &owlry, plugin_id)?; - action::register_action_api(lua, &owlry, plugin_id)?; - theme::register_theme_api(lua, &owlry, plugin_id, plugin_dir)?; - - // Set owlry as global - globals.set("owlry", owlry)?; - - Ok(()) -} - -/// Get provider registrations from the Lua runtime -/// -/// Returns all providers that were registered via `owlry.provider.register()` -pub fn get_provider_registrations(lua: &Lua) -> LuaResult> { - provider::get_registrations(lua) -} diff --git a/crates/owlry-core/src/plugins/api/process.rs b/crates/owlry-core/src/plugins/api/process.rs deleted file mode 100644 index aaa69fb..0000000 --- a/crates/owlry-core/src/plugins/api/process.rs +++ /dev/null @@ -1,213 +0,0 @@ -//! Process and environment APIs for Lua plugins -//! -//! Provides: -//! - `owlry.process.run(cmd)` - Run a shell command and return output -//! - `owlry.process.exists(cmd)` - Check if a command exists in PATH -//! - `owlry.env.get(name)` - Get an environment variable -//! - `owlry.env.set(name, value)` - Set an environment variable (for plugin scope) - -use mlua::{Lua, Result as LuaResult, Table}; -use std::process::Command; - -/// Register process-related APIs -pub fn register_process_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { - let process_table = lua.create_table()?; - - // owlry.process.run(cmd) -> { stdout, stderr, exit_code, success } - // Runs a shell command and returns the result - process_table.set( - "run", - lua.create_function(|lua, cmd: String| { - log::debug!("[plugin] process.run: {}", cmd); - - let output = Command::new("sh") - .arg("-c") - .arg(&cmd) - .output() - .map_err(|e| mlua::Error::external(format!("Failed to run command: {}", e)))?; - - let result = lua.create_table()?; - result.set( - "stdout", - String::from_utf8_lossy(&output.stdout).to_string(), - )?; - result.set( - "stderr", - String::from_utf8_lossy(&output.stderr).to_string(), - )?; - result.set("exit_code", output.status.code().unwrap_or(-1))?; - result.set("success", output.status.success())?; - - Ok(result) - })?, - )?; - - // owlry.process.run_lines(cmd) -> table of lines - // Convenience function that runs a command and returns stdout split into lines - process_table.set( - "run_lines", - lua.create_function(|lua, cmd: String| { - log::debug!("[plugin] process.run_lines: {}", cmd); - - let output = Command::new("sh") - .arg("-c") - .arg(&cmd) - .output() - .map_err(|e| mlua::Error::external(format!("Failed to run command: {}", e)))?; - - if !output.status.success() { - return Err(mlua::Error::external(format!( - "Command failed with exit code {}: {}", - output.status.code().unwrap_or(-1), - String::from_utf8_lossy(&output.stderr) - ))); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let lines: Vec<&str> = stdout.lines().collect(); - - let result = lua.create_table()?; - for (i, line) in lines.iter().enumerate() { - result.set(i + 1, *line)?; - } - - Ok(result) - })?, - )?; - - // owlry.process.exists(cmd) -> boolean - // Checks if a command exists in PATH - process_table.set( - "exists", - lua.create_function(|_lua, cmd: String| { - let exists = Command::new("which") - .arg(&cmd) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - - Ok(exists) - })?, - )?; - - owlry.set("process", process_table)?; - Ok(()) -} - -/// Register environment variable APIs -pub fn register_env_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { - let env_table = lua.create_table()?; - - // owlry.env.get(name) -> string or nil - env_table.set( - "get", - lua.create_function(|_lua, name: String| Ok(std::env::var(&name).ok()))?, - )?; - - // owlry.env.get_or(name, default) -> string - env_table.set( - "get_or", - lua.create_function(|_lua, (name, default): (String, String)| { - Ok(std::env::var(&name).unwrap_or(default)) - })?, - )?; - - // owlry.env.home() -> string - // Convenience function to get home directory - env_table.set( - "home", - lua.create_function(|_lua, ()| { - Ok(dirs::home_dir().map(|p| p.to_string_lossy().to_string())) - })?, - )?; - - owlry.set("env", env_table)?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn setup_lua() -> Lua { - let lua = Lua::new(); - let owlry = lua.create_table().unwrap(); - register_process_api(&lua, &owlry).unwrap(); - register_env_api(&lua, &owlry).unwrap(); - lua.globals().set("owlry", owlry).unwrap(); - lua - } - - #[test] - fn test_process_run() { - let lua = setup_lua(); - let chunk = lua.load(r#"return owlry.process.run("echo hello")"#); - let result: Table = chunk.call(()).unwrap(); - - assert_eq!(result.get::("success").unwrap(), true); - assert_eq!(result.get::("exit_code").unwrap(), 0); - assert!(result.get::("stdout").unwrap().contains("hello")); - } - - #[test] - fn test_process_run_lines() { - let lua = setup_lua(); - let chunk = lua.load(r#"return owlry.process.run_lines("echo -e 'line1\nline2\nline3'")"#); - let result: Table = chunk.call(()).unwrap(); - - assert_eq!(result.get::(1).unwrap(), "line1"); - assert_eq!(result.get::(2).unwrap(), "line2"); - assert_eq!(result.get::(3).unwrap(), "line3"); - } - - #[test] - fn test_process_exists() { - let lua = setup_lua(); - - // 'sh' should always exist - let chunk = lua.load(r#"return owlry.process.exists("sh")"#); - let exists: bool = chunk.call(()).unwrap(); - assert!(exists); - - // Made-up command should not exist - let chunk = lua - .load(r#"return owlry.process.exists("this_command_definitely_does_not_exist_12345")"#); - let not_exists: bool = chunk.call(()).unwrap(); - assert!(!not_exists); - } - - #[test] - fn test_env_get() { - let lua = setup_lua(); - - // HOME should be set on any Unix system - let chunk = lua.load(r#"return owlry.env.get("HOME")"#); - let home: Option = chunk.call(()).unwrap(); - assert!(home.is_some()); - - // Non-existent variable should return nil - let chunk = lua.load(r#"return owlry.env.get("THIS_VAR_DOES_NOT_EXIST_12345")"#); - let missing: Option = chunk.call(()).unwrap(); - assert!(missing.is_none()); - } - - #[test] - fn test_env_get_or() { - let lua = setup_lua(); - - let chunk = lua - .load(r#"return owlry.env.get_or("THIS_VAR_DOES_NOT_EXIST_12345", "default_value")"#); - let result: String = chunk.call(()).unwrap(); - assert_eq!(result, "default_value"); - } - - #[test] - fn test_env_home() { - let lua = setup_lua(); - - let chunk = lua.load(r#"return owlry.env.home()"#); - let home: Option = chunk.call(()).unwrap(); - assert!(home.is_some()); - assert!(home.unwrap().starts_with('/')); - } -} diff --git a/crates/owlry-core/src/plugins/api/provider.rs b/crates/owlry-core/src/plugins/api/provider.rs deleted file mode 100644 index 124c240..0000000 --- a/crates/owlry-core/src/plugins/api/provider.rs +++ /dev/null @@ -1,315 +0,0 @@ -//! Provider registration API for Lua plugins -//! -//! Allows plugins to register providers via `owlry.provider.register()` - -use mlua::{Function, Lua, Result as LuaResult, Table}; - -/// Provider registration data extracted from Lua -#[derive(Debug, Clone)] -#[allow(dead_code)] // Some fields are for future use -pub struct ProviderRegistration { - /// Provider name (used for filtering/identification) - pub name: String, - /// Human-readable display name - pub display_name: String, - /// Provider type ID (for badge/filtering) - pub type_id: String, - /// Default icon name - pub default_icon: String, - /// Whether this is a static provider (refresh once) or dynamic (query-based) - pub is_static: bool, - /// Prefix to trigger this provider (e.g., ":" for commands) - pub prefix: Option, -} - -/// Register owlry.provider.* API -pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { - let provider_table = lua.create_table()?; - - // Initialize registry for storing provider registrations - let registrations: Table = lua.create_table()?; - lua.set_named_registry_value("provider_registrations", registrations)?; - - // owlry.provider.register(config) - Register a new provider - provider_table.set( - "register", - lua.create_function(|lua, config: Table| { - // Extract required fields - let name: String = config - .get("name") - .map_err(|_| mlua::Error::external("provider.register: 'name' is required"))?; - - let _display_name: String = config.get("display_name").unwrap_or_else(|_| name.clone()); - - let type_id: String = config - .get("type_id") - .unwrap_or_else(|_| name.replace('-', "_")); - - let _default_icon: String = config - .get("default_icon") - .unwrap_or_else(|_| "application-x-executable".to_string()); - - let _prefix: Option = config.get("prefix").ok(); - - // Check for refresh function (static provider) or query function (dynamic) - let has_refresh = config.get::("refresh").is_ok(); - let has_query = config.get::("query").is_ok(); - - if !has_refresh && !has_query { - return Err(mlua::Error::external( - "provider.register: either 'refresh' or 'query' function is required", - )); - } - - let is_static = has_refresh; - - log::info!( - "[plugin] Registered provider '{}' (type: {}, static: {})", - name, - type_id, - is_static - ); - - // Store the config in registry for later retrieval - let registrations: Table = lua.named_registry_value("provider_registrations")?; - registrations.set(name.clone(), config)?; - - Ok(name) - })?, - )?; - - owlry.set("provider", provider_table)?; - Ok(()) -} - -/// Get all provider registrations from the Lua runtime -pub fn get_registrations(lua: &Lua) -> LuaResult> { - let registrations: Table = lua.named_registry_value("provider_registrations")?; - let mut result = Vec::new(); - - for pair in registrations.pairs::() { - let (name, config) = pair?; - - let display_name: String = config.get("display_name").unwrap_or_else(|_| name.clone()); - let type_id: String = config - .get("type_id") - .unwrap_or_else(|_| name.replace('-', "_")); - let default_icon: String = config - .get("default_icon") - .unwrap_or_else(|_| "application-x-executable".to_string()); - let prefix: Option = config.get("prefix").ok(); - let is_static = config.get::("refresh").is_ok(); - - result.push(ProviderRegistration { - name, - display_name, - type_id, - default_icon, - is_static, - prefix, - }); - } - - Ok(result) -} - -/// Call a provider's refresh function and extract items -pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult> { - let registrations: Table = lua.named_registry_value("provider_registrations")?; - let config: Table = registrations.get(provider_name)?; - let refresh: Function = config.get("refresh")?; - - let items: Table = refresh.call(())?; - extract_items(&items) -} - -/// Call a provider's query function with a query string -#[allow(dead_code)] // Will be used for dynamic query providers -pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult> { - let registrations: Table = lua.named_registry_value("provider_registrations")?; - let config: Table = registrations.get(provider_name)?; - let query_fn: Function = config.get("query")?; - - let items: Table = query_fn.call(query.to_string())?; - extract_items(&items) -} - -/// Item data from a plugin provider -#[derive(Debug, Clone)] -#[allow(dead_code)] // data field is for future action handlers -pub struct PluginItem { - pub id: String, - pub name: String, - pub description: Option, - pub icon: Option, - pub command: Option, - pub terminal: bool, - pub tags: Vec, - /// Custom data passed to action handlers - pub data: Option, -} - -/// Extract items from a Lua table returned by refresh/query -fn extract_items(items: &Table) -> LuaResult> { - let mut result = Vec::new(); - - for pair in items.clone().pairs::() { - let (_, item) = pair?; - - let id: String = item.get("id")?; - let name: String = item.get("name")?; - let description: Option = item.get("description").ok(); - let icon: Option = item.get("icon").ok(); - let command: Option = item.get("command").ok(); - let terminal: bool = item.get("terminal").unwrap_or(false); - let data: Option = item.get("data").ok(); - - // Extract tags array - let tags: Vec = if let Ok(tags_table) = item.get::
("tags") { - tags_table - .pairs::() - .filter_map(|r| r.ok()) - .map(|(_, v)| v) - .collect() - } else { - Vec::new() - }; - - result.push(PluginItem { - id, - name, - description, - icon, - command, - terminal, - tags, - data, - }); - } - - Ok(result) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn create_test_lua() -> Lua { - let lua = Lua::new(); - let owlry = lua.create_table().unwrap(); - register_provider_api(&lua, &owlry).unwrap(); - lua.globals().set("owlry", owlry).unwrap(); - lua - } - - #[test] - fn test_register_static_provider() { - let lua = create_test_lua(); - - let script = r#" - owlry.provider.register({ - name = "test-provider", - display_name = "Test Provider", - type_id = "test", - default_icon = "test-icon", - refresh = function() - return { - { id = "1", name = "Item 1", description = "First item" }, - { id = "2", name = "Item 2", command = "echo hello" }, - } - end - }) - "#; - lua.load(script).call::<()>(()).unwrap(); - - let registrations = get_registrations(&lua).unwrap(); - assert_eq!(registrations.len(), 1); - assert_eq!(registrations[0].name, "test-provider"); - assert_eq!(registrations[0].display_name, "Test Provider"); - assert!(registrations[0].is_static); - } - - #[test] - fn test_register_dynamic_provider() { - let lua = create_test_lua(); - - let script = r#" - owlry.provider.register({ - name = "search", - prefix = "?", - query = function(q) - return { - { id = "result", name = "Result for: " .. q } - } - end - }) - "#; - lua.load(script).call::<()>(()).unwrap(); - - let registrations = get_registrations(&lua).unwrap(); - assert_eq!(registrations.len(), 1); - assert!(!registrations[0].is_static); - assert_eq!(registrations[0].prefix, Some("?".to_string())); - } - - #[test] - fn test_call_refresh() { - let lua = create_test_lua(); - - let script = r#" - owlry.provider.register({ - name = "items", - refresh = function() - return { - { id = "a", name = "Alpha", tags = {"one", "two"} }, - { id = "b", name = "Beta", terminal = true }, - } - end - }) - "#; - lua.load(script).call::<()>(()).unwrap(); - - let items = call_refresh(&lua, "items").unwrap(); - assert_eq!(items.len(), 2); - assert_eq!(items[0].id, "a"); - assert_eq!(items[0].name, "Alpha"); - assert_eq!(items[0].tags, vec!["one", "two"]); - assert!(!items[0].terminal); - assert_eq!(items[1].id, "b"); - assert!(items[1].terminal); - } - - #[test] - fn test_call_query() { - let lua = create_test_lua(); - - let script = r#" - owlry.provider.register({ - name = "search", - query = function(q) - return { - { id = "1", name = "Found: " .. q } - } - end - }) - "#; - lua.load(script).call::<()>(()).unwrap(); - - let items = call_query(&lua, "search", "hello").unwrap(); - assert_eq!(items.len(), 1); - assert_eq!(items[0].name, "Found: hello"); - } - - #[test] - fn test_register_missing_function() { - let lua = create_test_lua(); - - let script = r#" - owlry.provider.register({ - name = "broken", - }) - "#; - let result = lua.load(script).call::<()>(()); - assert!(result.is_err()); - } -} diff --git a/crates/owlry-core/src/plugins/api/theme.rs b/crates/owlry-core/src/plugins/api/theme.rs deleted file mode 100644 index 5e10cbb..0000000 --- a/crates/owlry-core/src/plugins/api/theme.rs +++ /dev/null @@ -1,286 +0,0 @@ -//! Theme API for Lua plugins -//! -//! Allows plugins to contribute CSS themes: -//! - `owlry.theme.register(config)` - Register a theme - -use mlua::{Lua, Result as LuaResult, Table, Value}; -use std::path::Path; - -/// Theme registration data -#[derive(Debug, Clone)] -#[allow(dead_code)] // Will be used by theme loading -pub struct ThemeRegistration { - /// Theme name (used in config) - pub name: String, - /// Human-readable display name - pub display_name: String, - /// CSS content - pub css: String, - /// Plugin that registered this theme - pub plugin_id: String, -} - -/// Register theme APIs -pub fn register_theme_api( - lua: &Lua, - owlry: &Table, - plugin_id: &str, - plugin_dir: &Path, -) -> LuaResult<()> { - let theme_table = lua.create_table()?; - let plugin_id_owned = plugin_id.to_string(); - let plugin_dir_owned = plugin_dir.to_path_buf(); - - // Initialize theme storage in Lua registry - if lua.named_registry_value::("themes")?.is_nil() { - let themes: Table = lua.create_table()?; - lua.set_named_registry_value("themes", themes)?; - } - - // owlry.theme.register(config) -> string (theme_name) - // config = { - // name = "dark-owl", - // display_name = "Dark Owl", -- optional, defaults to name - // css = "...", -- CSS string - // -- OR - // css_file = "theme.css" -- path relative to plugin dir - // } - let plugin_id_for_register = plugin_id_owned.clone(); - let plugin_dir_for_register = plugin_dir_owned.clone(); - theme_table.set( - "register", - lua.create_function(move |lua, config: Table| { - // Extract required fields - let name: String = config - .get("name") - .map_err(|_| mlua::Error::external("theme.register: 'name' is required"))?; - - let display_name: String = config.get("display_name").unwrap_or_else(|_| name.clone()); - - // Get CSS either directly or from file - let css: String = if let Ok(css_str) = config.get::("css") { - css_str - } else if let Ok(css_file) = config.get::("css_file") { - let css_path = plugin_dir_for_register.join(&css_file); - std::fs::read_to_string(&css_path).map_err(|e| { - mlua::Error::external(format!( - "Failed to read CSS file '{}': {}", - css_path.display(), - e - )) - })? - } else { - return Err(mlua::Error::external( - "theme.register: either 'css' or 'css_file' is required", - )); - }; - - // Store theme in registry - let themes: Table = lua.named_registry_value("themes")?; - - let theme_entry = lua.create_table()?; - theme_entry.set("name", name.clone())?; - theme_entry.set("display_name", display_name.clone())?; - theme_entry.set("css", css)?; - theme_entry.set("plugin_id", plugin_id_for_register.clone())?; - - themes.set(name.clone(), theme_entry)?; - - log::info!( - "[plugin:{}] Registered theme '{}'", - plugin_id_for_register, - name - ); - - Ok(name) - })?, - )?; - - // owlry.theme.unregister(name) -> boolean - theme_table.set( - "unregister", - lua.create_function(|lua, name: String| { - let themes: Table = lua.named_registry_value("themes")?; - - if themes.contains_key(name.clone())? { - themes.set(name, Value::Nil)?; - Ok(true) - } else { - Ok(false) - } - })?, - )?; - - // owlry.theme.list() -> table of theme names - theme_table.set( - "list", - lua.create_function(|lua, ()| { - let themes: Table = match lua.named_registry_value("themes") { - Ok(t) => t, - Err(_) => return lua.create_table(), - }; - - let result = lua.create_table()?; - let mut i = 1; - - for pair in themes.pairs::() { - let (name, _) = pair?; - result.set(i, name)?; - i += 1; - } - - Ok(result) - })?, - )?; - - owlry.set("theme", theme_table)?; - Ok(()) -} - -/// Get all registered themes from a Lua runtime -#[allow(dead_code)] // Will be used by theme system -pub fn get_themes(lua: &Lua) -> LuaResult> { - let themes: Table = match lua.named_registry_value("themes") { - Ok(t) => t, - Err(_) => return Ok(Vec::new()), - }; - - let mut result = Vec::new(); - - for pair in themes.pairs::() { - let (_, entry) = pair?; - - let name: String = entry.get("name")?; - let display_name: String = entry.get("display_name")?; - let css: String = entry.get("css")?; - let plugin_id: String = entry.get("plugin_id")?; - - result.push(ThemeRegistration { - name, - display_name, - css, - plugin_id, - }); - } - - Ok(result) -} - -/// Get a specific theme's CSS by name -#[allow(dead_code)] // Will be used by theme loading -pub fn get_theme_css(lua: &Lua, name: &str) -> LuaResult> { - let themes: Table = match lua.named_registry_value("themes") { - Ok(t) => t, - Err(_) => return Ok(None), - }; - - if let Ok(entry) = themes.get::
(name) { - let css: String = entry.get("css")?; - Ok(Some(css)) - } else { - Ok(None) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn setup_lua(plugin_id: &str, plugin_dir: &Path) -> Lua { - let lua = Lua::new(); - let owlry = lua.create_table().unwrap(); - register_theme_api(&lua, &owlry, plugin_id, plugin_dir).unwrap(); - lua.globals().set("owlry", owlry).unwrap(); - lua - } - - #[test] - fn test_theme_registration_inline() { - let temp = TempDir::new().unwrap(); - let lua = setup_lua("test-plugin", temp.path()); - - let chunk = lua.load( - r#" - return owlry.theme.register({ - name = "my-theme", - display_name = "My Theme", - css = ".owlry-window { background: #333; }" - }) - "#, - ); - let name: String = chunk.call(()).unwrap(); - assert_eq!(name, "my-theme"); - - let themes = get_themes(&lua).unwrap(); - assert_eq!(themes.len(), 1); - assert_eq!(themes[0].display_name, "My Theme"); - assert!(themes[0].css.contains("background: #333")); - } - - #[test] - fn test_theme_registration_file() { - let temp = TempDir::new().unwrap(); - let css_content = ".owlry-window { background: #444; }"; - std::fs::write(temp.path().join("theme.css"), css_content).unwrap(); - - let lua = setup_lua("test-plugin", temp.path()); - - let chunk = lua.load( - r#" - return owlry.theme.register({ - name = "file-theme", - css_file = "theme.css" - }) - "#, - ); - let name: String = chunk.call(()).unwrap(); - assert_eq!(name, "file-theme"); - - let css = get_theme_css(&lua, "file-theme").unwrap(); - assert!(css.is_some()); - assert!(css.unwrap().contains("background: #444")); - } - - #[test] - fn test_theme_list() { - let temp = TempDir::new().unwrap(); - let lua = setup_lua("test-plugin", temp.path()); - - let chunk = lua.load( - r#" - owlry.theme.register({ name = "theme1", css = "a{}" }) - owlry.theme.register({ name = "theme2", css = "b{}" }) - return owlry.theme.list() - "#, - ); - let list: Table = chunk.call(()).unwrap(); - - let mut names: Vec = Vec::new(); - for pair in list.pairs::() { - let (_, name) = pair.unwrap(); - names.push(name); - } - assert_eq!(names.len(), 2); - assert!(names.contains(&"theme1".to_string())); - assert!(names.contains(&"theme2".to_string())); - } - - #[test] - fn test_theme_unregister() { - let temp = TempDir::new().unwrap(); - let lua = setup_lua("test-plugin", temp.path()); - - let chunk = lua.load( - r#" - owlry.theme.register({ name = "temp-theme", css = "c{}" }) - return owlry.theme.unregister("temp-theme") - "#, - ); - let unregistered: bool = chunk.call(()).unwrap(); - assert!(unregistered); - - let themes = get_themes(&lua).unwrap(); - assert_eq!(themes.len(), 0); - } -} diff --git a/crates/owlry-core/src/plugins/api/utils.rs b/crates/owlry-core/src/plugins/api/utils.rs deleted file mode 100644 index 9c0a8d0..0000000 --- a/crates/owlry-core/src/plugins/api/utils.rs +++ /dev/null @@ -1,569 +0,0 @@ -//! Utility APIs: log, path, fs, json - -use mlua::{Lua, Result as LuaResult, Table, Value}; -use std::path::{Path, PathBuf}; - -/// Register owlry.log.* API -/// -/// Provides: debug, info, warn, error -pub fn register_log_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { - let log_table = lua.create_table()?; - - log_table.set( - "debug", - lua.create_function(|_, msg: String| { - log::debug!("[plugin] {}", msg); - Ok(()) - })?, - )?; - - log_table.set( - "info", - lua.create_function(|_, msg: String| { - log::info!("[plugin] {}", msg); - Ok(()) - })?, - )?; - - log_table.set( - "warn", - lua.create_function(|_, msg: String| { - log::warn!("[plugin] {}", msg); - Ok(()) - })?, - )?; - - log_table.set( - "error", - lua.create_function(|_, msg: String| { - log::error!("[plugin] {}", msg); - Ok(()) - })?, - )?; - - owlry.set("log", log_table)?; - Ok(()) -} - -/// Register owlry.path.* API -/// -/// Provides XDG directory helpers: config, data, cache, home, plugin_dir -pub fn register_path_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> { - let path_table = lua.create_table()?; - let plugin_dir_str = plugin_dir.to_string_lossy().to_string(); - - // owlry.path.config() -> ~/.config/owlry - path_table.set( - "config", - lua.create_function(|_, ()| { - let path = dirs::config_dir() - .map(|p| p.join("owlry")) - .unwrap_or_default(); - Ok(path.to_string_lossy().to_string()) - })?, - )?; - - // owlry.path.data() -> ~/.local/share/owlry - path_table.set( - "data", - lua.create_function(|_, ()| { - let path = dirs::data_dir() - .map(|p| p.join("owlry")) - .unwrap_or_default(); - Ok(path.to_string_lossy().to_string()) - })?, - )?; - - // owlry.path.cache() -> ~/.cache/owlry - path_table.set( - "cache", - lua.create_function(|_, ()| { - let path = dirs::cache_dir() - .map(|p| p.join("owlry")) - .unwrap_or_default(); - Ok(path.to_string_lossy().to_string()) - })?, - )?; - - // owlry.path.home() -> ~ - path_table.set( - "home", - lua.create_function(|_, ()| { - let path = dirs::home_dir().unwrap_or_default(); - Ok(path.to_string_lossy().to_string()) - })?, - )?; - - // owlry.path.join(base, ...) -> joined path - path_table.set( - "join", - lua.create_function(|_, parts: mlua::Variadic| { - let mut path = PathBuf::new(); - for part in parts { - path.push(part); - } - Ok(path.to_string_lossy().to_string()) - })?, - )?; - - // owlry.path.exists(path) -> bool - path_table.set( - "exists", - lua.create_function(|_, path: String| Ok(Path::new(&path).exists()))?, - )?; - - // owlry.path.is_file(path) -> bool - path_table.set( - "is_file", - lua.create_function(|_, path: String| Ok(Path::new(&path).is_file()))?, - )?; - - // owlry.path.is_dir(path) -> bool - path_table.set( - "is_dir", - lua.create_function(|_, path: String| Ok(Path::new(&path).is_dir()))?, - )?; - - // owlry.path.expand(path) -> expanded path (handles ~) - path_table.set( - "expand", - lua.create_function(|_, path: String| { - let expanded = if let Some(rest) = path.strip_prefix("~/") { - if let Some(home) = dirs::home_dir() { - home.join(rest).to_string_lossy().to_string() - } else { - path - } - } else if path == "~" { - dirs::home_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or(path) - } else { - path - }; - Ok(expanded) - })?, - )?; - - // owlry.path.plugin_dir() -> this plugin's directory - path_table.set( - "plugin_dir", - lua.create_function(move |_, ()| Ok(plugin_dir_str.clone()))?, - )?; - - owlry.set("path", path_table)?; - Ok(()) -} - -/// Register owlry.fs.* API -/// -/// Provides filesystem operations within the plugin's directory -pub fn register_fs_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> { - let fs_table = lua.create_table()?; - let plugin_dir_str = plugin_dir.to_string_lossy().to_string(); - - // Store plugin directory in registry for access in closures - lua.set_named_registry_value("plugin_dir", plugin_dir_str.clone())?; - - // owlry.fs.read(path) -> string or nil, error - fs_table.set( - "read", - lua.create_function(|lua, path: String| { - let plugin_dir: String = lua.named_registry_value("plugin_dir")?; - let full_path = resolve_plugin_path(&plugin_dir, &path); - - match std::fs::read_to_string(&full_path) { - Ok(content) => Ok((Some(content), Value::Nil)), - Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), - } - })?, - )?; - - // owlry.fs.write(path, content) -> bool, error - fs_table.set( - "write", - lua.create_function(|lua, (path, content): (String, String)| { - let plugin_dir: String = lua.named_registry_value("plugin_dir")?; - let full_path = resolve_plugin_path(&plugin_dir, &path); - - // Ensure parent directory exists - if let Some(parent) = full_path.parent() - && !parent.exists() - && let Err(e) = std::fs::create_dir_all(parent) - { - return Ok((false, Value::String(lua.create_string(e.to_string())?))); - } - - match std::fs::write(&full_path, content) { - Ok(()) => Ok((true, Value::Nil)), - Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))), - } - })?, - )?; - - // owlry.fs.list(path) -> array of filenames or nil, error - fs_table.set( - "list", - lua.create_function(|lua, path: Option| { - let plugin_dir: String = lua.named_registry_value("plugin_dir")?; - let dir_path = path - .map(|p| resolve_plugin_path(&plugin_dir, &p)) - .unwrap_or_else(|| PathBuf::from(&plugin_dir)); - - match std::fs::read_dir(&dir_path) { - Ok(entries) => { - let names: Vec = entries - .filter_map(|e| e.ok()) - .filter_map(|e| e.file_name().into_string().ok()) - .collect(); - let table = lua.create_sequence_from(names)?; - Ok((Some(table), Value::Nil)) - } - Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), - } - })?, - )?; - - // owlry.fs.exists(path) -> bool - fs_table.set( - "exists", - lua.create_function(|lua, path: String| { - let plugin_dir: String = lua.named_registry_value("plugin_dir")?; - let full_path = resolve_plugin_path(&plugin_dir, &path); - Ok(full_path.exists()) - })?, - )?; - - // owlry.fs.mkdir(path) -> bool, error - fs_table.set( - "mkdir", - lua.create_function(|lua, path: String| { - let plugin_dir: String = lua.named_registry_value("plugin_dir")?; - let full_path = resolve_plugin_path(&plugin_dir, &path); - - match std::fs::create_dir_all(&full_path) { - Ok(()) => Ok((true, Value::Nil)), - Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))), - } - })?, - )?; - - // owlry.fs.remove(path) -> bool, error - fs_table.set( - "remove", - lua.create_function(|lua, path: String| { - let plugin_dir: String = lua.named_registry_value("plugin_dir")?; - let full_path = resolve_plugin_path(&plugin_dir, &path); - - let result = if full_path.is_dir() { - std::fs::remove_dir_all(&full_path) - } else { - std::fs::remove_file(&full_path) - }; - - match result { - Ok(()) => Ok((true, Value::Nil)), - Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))), - } - })?, - )?; - - // owlry.fs.is_file(path) -> bool - fs_table.set( - "is_file", - lua.create_function(|lua, path: String| { - let plugin_dir: String = lua.named_registry_value("plugin_dir")?; - let full_path = resolve_plugin_path(&plugin_dir, &path); - Ok(full_path.is_file()) - })?, - )?; - - // owlry.fs.is_dir(path) -> bool - fs_table.set( - "is_dir", - lua.create_function(|lua, path: String| { - let plugin_dir: String = lua.named_registry_value("plugin_dir")?; - let full_path = resolve_plugin_path(&plugin_dir, &path); - Ok(full_path.is_dir()) - })?, - )?; - - // owlry.fs.is_executable(path) -> bool - #[cfg(unix)] - fs_table.set( - "is_executable", - lua.create_function(|lua, path: String| { - use std::os::unix::fs::PermissionsExt; - let plugin_dir: String = lua.named_registry_value("plugin_dir")?; - let full_path = resolve_plugin_path(&plugin_dir, &path); - let is_exec = full_path - .metadata() - .map(|m| m.permissions().mode() & 0o111 != 0) - .unwrap_or(false); - Ok(is_exec) - })?, - )?; - - // owlry.fs.plugin_dir() -> plugin directory path - let dir_clone = plugin_dir_str.clone(); - fs_table.set( - "plugin_dir", - lua.create_function(move |_, ()| Ok(dir_clone.clone()))?, - )?; - - owlry.set("fs", fs_table)?; - Ok(()) -} - -/// Resolve a path relative to the plugin directory -/// -/// If the path is absolute, returns it as-is (for paths within allowed directories). -/// If relative, joins with plugin directory. -fn resolve_plugin_path(plugin_dir: &str, path: &str) -> PathBuf { - let path = Path::new(path); - if path.is_absolute() { - path.to_path_buf() - } else { - Path::new(plugin_dir).join(path) - } -} - -/// Register owlry.json.* API -/// -/// Provides JSON encoding/decoding -pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { - let json_table = lua.create_table()?; - - // owlry.json.encode(value) -> string or nil, error - json_table.set( - "encode", - lua.create_function(|lua, value: Value| match lua_to_json(&value) { - Ok(json) => match serde_json::to_string(&json) { - Ok(s) => Ok((Some(s), Value::Nil)), - Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), - }, - Err(e) => Ok((None, Value::String(lua.create_string(&e)?))), - })?, - )?; - - // owlry.json.encode_pretty(value) -> string or nil, error - json_table.set( - "encode_pretty", - lua.create_function(|lua, value: Value| match lua_to_json(&value) { - Ok(json) => match serde_json::to_string_pretty(&json) { - Ok(s) => Ok((Some(s), Value::Nil)), - Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), - }, - Err(e) => Ok((None, Value::String(lua.create_string(&e)?))), - })?, - )?; - - // owlry.json.decode(string) -> value or nil, error - json_table.set( - "decode", - lua.create_function(|lua, s: String| { - match serde_json::from_str::(&s) { - Ok(json) => match json_to_lua(lua, &json) { - Ok(value) => Ok((Some(value), Value::Nil)), - Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), - }, - Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), - } - })?, - )?; - - owlry.set("json", json_table)?; - Ok(()) -} - -/// Convert Lua value to JSON -fn lua_to_json(value: &Value) -> Result { - match value { - Value::Nil => Ok(serde_json::Value::Null), - Value::Boolean(b) => Ok(serde_json::Value::Bool(*b)), - Value::Integer(i) => Ok(serde_json::Value::Number((*i).into())), - Value::Number(n) => serde_json::Number::from_f64(*n) - .map(serde_json::Value::Number) - .ok_or_else(|| "Invalid number".to_string()), - Value::String(s) => Ok(serde_json::Value::String( - s.to_str().map_err(|e| e.to_string())?.to_string(), - )), - Value::Table(t) => { - // Check if it's an array (sequential integer keys starting from 1) - let len = t.raw_len(); - let is_array = len > 0 - && (1..=len).all(|i| { - t.raw_get::(i) - .is_ok_and(|v| !matches!(v, Value::Nil)) - }); - - if is_array { - let arr: Result, String> = (1..=len) - .map(|i| { - let v: Value = t.raw_get(i).map_err(|e| e.to_string())?; - lua_to_json(&v) - }) - .collect(); - Ok(serde_json::Value::Array(arr?)) - } else { - let mut map = serde_json::Map::new(); - for pair in t.clone().pairs::() { - let (k, v) = pair.map_err(|e| e.to_string())?; - let key = match k { - Value::String(s) => s.to_str().map_err(|e| e.to_string())?.to_string(), - Value::Integer(i) => i.to_string(), - _ => return Err("JSON object keys must be strings".to_string()), - }; - map.insert(key, lua_to_json(&v)?); - } - Ok(serde_json::Value::Object(map)) - } - } - _ => Err(format!("Cannot convert {:?} to JSON", value)), - } -} - -/// Convert JSON to Lua value -fn json_to_lua(lua: &Lua, json: &serde_json::Value) -> LuaResult { - match json { - serde_json::Value::Null => Ok(Value::Nil), - serde_json::Value::Bool(b) => Ok(Value::Boolean(*b)), - serde_json::Value::Number(n) => { - if let Some(i) = n.as_i64() { - Ok(Value::Integer(i)) - } else if let Some(f) = n.as_f64() { - Ok(Value::Number(f)) - } else { - Ok(Value::Nil) - } - } - serde_json::Value::String(s) => Ok(Value::String(lua.create_string(s)?)), - serde_json::Value::Array(arr) => { - let table = lua.create_table()?; - for (i, v) in arr.iter().enumerate() { - table.set(i + 1, json_to_lua(lua, v)?)?; - } - Ok(Value::Table(table)) - } - serde_json::Value::Object(obj) => { - let table = lua.create_table()?; - for (k, v) in obj { - table.set(k.as_str(), json_to_lua(lua, v)?)?; - } - Ok(Value::Table(table)) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn create_test_lua() -> (Lua, TempDir) { - let lua = Lua::new(); - let temp = TempDir::new().unwrap(); - let owlry = lua.create_table().unwrap(); - register_log_api(&lua, &owlry).unwrap(); - register_path_api(&lua, &owlry, temp.path()).unwrap(); - register_fs_api(&lua, &owlry, temp.path()).unwrap(); - register_json_api(&lua, &owlry).unwrap(); - lua.globals().set("owlry", owlry).unwrap(); - (lua, temp) - } - - #[test] - fn test_log_api() { - let (lua, _temp) = create_test_lua(); - // Just verify it doesn't panic - using call instead of the e-word - lua.load("owlry.log.info('test message')") - .call::<()>(()) - .unwrap(); - lua.load("owlry.log.debug('debug')").call::<()>(()).unwrap(); - lua.load("owlry.log.warn('warning')") - .call::<()>(()) - .unwrap(); - lua.load("owlry.log.error('error')").call::<()>(()).unwrap(); - } - - #[test] - fn test_path_api() { - let (lua, _temp) = create_test_lua(); - - let home: String = lua.load("return owlry.path.home()").call(()).unwrap(); - assert!(!home.is_empty()); - - let joined: String = lua - .load("return owlry.path.join('a', 'b', 'c')") - .call(()) - .unwrap(); - assert!(joined.contains("a") && joined.contains("b") && joined.contains("c")); - - let expanded: String = lua - .load("return owlry.path.expand('~/test')") - .call(()) - .unwrap(); - assert!(!expanded.starts_with("~")); - } - - #[test] - fn test_fs_api() { - let (lua, temp) = create_test_lua(); - - // Test write and read - lua.load("owlry.fs.write('test.txt', 'hello world')") - .call::<()>(()) - .unwrap(); - - assert!(temp.path().join("test.txt").exists()); - - let content: String = lua - .load("return owlry.fs.read('test.txt')") - .call(()) - .unwrap(); - assert_eq!(content, "hello world"); - - // Test exists - let exists: bool = lua - .load("return owlry.fs.exists('test.txt')") - .call(()) - .unwrap(); - assert!(exists); - - // Test list - let script = r#" - local files = owlry.fs.list() - return #files - "#; - let count: i32 = lua.load(script).call(()).unwrap(); - assert!(count >= 1); - } - - #[test] - fn test_json_api() { - let (lua, _temp) = create_test_lua(); - - // Test encode - let encoded: String = lua - .load(r#"return owlry.json.encode({name = "test", value = 42})"#) - .call(()) - .unwrap(); - assert!(encoded.contains("test") && encoded.contains("42")); - - // Test decode - let script = r#" - local data = owlry.json.decode('{"name":"hello","num":123}') - return data.name, data.num - "#; - let (name, num): (String, i32) = lua.load(script).call(()).unwrap(); - assert_eq!(name, "hello"); - assert_eq!(num, 123); - - // Test array encoding - let encoded: String = lua - .load(r#"return owlry.json.encode({1, 2, 3})"#) - .call(()) - .unwrap(); - assert_eq!(encoded, "[1,2,3]"); - } -} diff --git a/crates/owlry-core/src/plugins/error.rs b/crates/owlry-core/src/plugins/error.rs deleted file mode 100644 index af6ce43..0000000 --- a/crates/owlry-core/src/plugins/error.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Plugin system error types - -use thiserror::Error; - -/// Errors that can occur in the plugin system -#[derive(Error, Debug)] -#[allow(dead_code)] // Some variants are for future use -pub enum PluginError { - #[error("Plugin '{0}' not found")] - NotFound(String), - - #[error("Invalid plugin manifest in '{plugin}': {message}")] - InvalidManifest { plugin: String, message: String }, - - #[error("Plugin '{plugin}' requires owlry {required}, but current version is {current}")] - VersionMismatch { - plugin: String, - required: String, - current: String, - }, - - #[error("Lua error in plugin '{plugin}': {message}")] - LuaError { plugin: String, message: String }, - - #[error("Plugin '{plugin}' timed out after {timeout_ms}ms")] - Timeout { plugin: String, timeout_ms: u64 }, - - #[error("Plugin '{plugin}' attempted forbidden operation: {operation}")] - SandboxViolation { plugin: String, operation: String }, - - #[error("Plugin '{0}' is already loaded")] - AlreadyLoaded(String), - - #[error("Plugin '{0}' is disabled")] - Disabled(String), - - #[error("Failed to load native plugin: {0}")] - LoadError(String), - - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - - #[error("TOML parsing error: {0}")] - TomlParse(#[from] toml::de::Error), - - #[error("JSON error: {0}")] - Json(#[from] serde_json::Error), -} - -/// Result type for plugin operations -pub type PluginResult = Result; diff --git a/crates/owlry-core/src/plugins/loader.rs b/crates/owlry-core/src/plugins/loader.rs deleted file mode 100644 index 632e39f..0000000 --- a/crates/owlry-core/src/plugins/loader.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! Lua plugin loading and initialization - -use std::path::PathBuf; - -use mlua::Lua; - -use super::api; -use super::error::{PluginError, PluginResult}; -use super::manifest::PluginManifest; -use super::runtime::{SandboxConfig, create_lua_runtime, load_file}; - -/// A loaded plugin instance -#[derive(Debug)] -pub struct LoadedPlugin { - /// Plugin manifest - pub manifest: PluginManifest, - /// Path to plugin directory - pub path: PathBuf, - /// Whether plugin is enabled - pub enabled: bool, - /// Lua runtime (None if not yet initialized) - lua: Option, -} - -impl LoadedPlugin { - /// Create a new loaded plugin (not yet initialized) - pub fn new(manifest: PluginManifest, path: PathBuf) -> Self { - Self { - manifest, - path, - enabled: true, - lua: None, - } - } - - /// Get the plugin ID - pub fn id(&self) -> &str { - &self.manifest.plugin.id - } - - /// Get the plugin name - #[allow(dead_code)] - pub fn name(&self) -> &str { - &self.manifest.plugin.name - } - - /// Initialize the Lua runtime and load the entry point - pub fn initialize(&mut self) -> PluginResult<()> { - if self.lua.is_some() { - return Ok(()); // Already initialized - } - - let sandbox = SandboxConfig::from_permissions(&self.manifest.permissions); - let lua = create_lua_runtime(&sandbox).map_err(|e| PluginError::LuaError { - plugin: self.id().to_string(), - message: e.to_string(), - })?; - - // Register owlry APIs before loading entry point - api::register_apis(&lua, &self.path, self.id()).map_err(|e| PluginError::LuaError { - plugin: self.id().to_string(), - message: format!("Failed to register APIs: {}", e), - })?; - - // Load the entry point file - let entry_path = self.path.join(&self.manifest.plugin.entry); - if !entry_path.exists() { - return Err(PluginError::InvalidManifest { - plugin: self.id().to_string(), - message: format!("Entry point '{}' not found", self.manifest.plugin.entry), - }); - } - - load_file(&lua, &entry_path).map_err(|e| PluginError::LuaError { - plugin: self.id().to_string(), - message: e.to_string(), - })?; - - self.lua = Some(lua); - Ok(()) - } - - /// Get provider registrations from this plugin - pub fn get_provider_registrations(&self) -> PluginResult> { - let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError { - plugin: self.id().to_string(), - message: "Plugin not initialized".to_string(), - })?; - - api::get_provider_registrations(lua).map_err(|e| PluginError::LuaError { - plugin: self.id().to_string(), - message: e.to_string(), - }) - } - - /// Call a provider's refresh function - pub fn call_provider_refresh( - &self, - provider_name: &str, - ) -> PluginResult> { - let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError { - plugin: self.id().to_string(), - message: "Plugin not initialized".to_string(), - })?; - - api::provider::call_refresh(lua, provider_name).map_err(|e| PluginError::LuaError { - plugin: self.id().to_string(), - message: e.to_string(), - }) - } - - /// Call a provider's query function - #[allow(dead_code)] // Will be used for dynamic query providers - pub fn call_provider_query( - &self, - provider_name: &str, - query: &str, - ) -> PluginResult> { - let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError { - plugin: self.id().to_string(), - message: "Plugin not initialized".to_string(), - })?; - - api::provider::call_query(lua, provider_name, query).map_err(|e| PluginError::LuaError { - plugin: self.id().to_string(), - message: e.to_string(), - }) - } - - /// Get a reference to the Lua runtime (if initialized) - #[allow(dead_code)] - pub fn lua(&self) -> Option<&Lua> { - self.lua.as_ref() - } - - /// Get a mutable reference to the Lua runtime (if initialized) - #[allow(dead_code)] - pub fn lua_mut(&mut self) -> Option<&mut Lua> { - self.lua.as_mut() - } -} - -// Note: discover_plugins and check_compatibility are in manifest.rs -// to avoid Lua dependency for plugin discovery. - -#[cfg(test)] -mod tests { - use super::super::manifest::{check_compatibility, discover_plugins}; - use super::*; - use std::fs; - use std::path::Path; - use tempfile::TempDir; - - fn create_test_plugin(dir: &Path, id: &str, name: &str) { - let plugin_dir = dir.join(id); - fs::create_dir_all(&plugin_dir).unwrap(); - - let manifest = format!( - r#" -[plugin] -id = "{}" -name = "{}" -version = "1.0.0" -"#, - id, name - ); - fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); - fs::write(plugin_dir.join("init.lua"), "-- empty plugin").unwrap(); - } - - #[test] - fn test_discover_plugins() { - let temp = TempDir::new().unwrap(); - let plugins_dir = temp.path(); - - create_test_plugin(plugins_dir, "test-plugin", "Test Plugin"); - create_test_plugin(plugins_dir, "another-plugin", "Another Plugin"); - - let plugins = discover_plugins(plugins_dir).unwrap(); - assert_eq!(plugins.len(), 2); - assert!(plugins.contains_key("test-plugin")); - assert!(plugins.contains_key("another-plugin")); - } - - #[test] - fn test_discover_plugins_empty_dir() { - let temp = TempDir::new().unwrap(); - let plugins = discover_plugins(temp.path()).unwrap(); - assert!(plugins.is_empty()); - } - - #[test] - fn test_discover_plugins_nonexistent_dir() { - let plugins = discover_plugins(Path::new("/nonexistent/path")).unwrap(); - assert!(plugins.is_empty()); - } - - #[test] - fn test_check_compatibility() { - let toml_str = r#" -[plugin] -id = "test" -name = "Test" -version = "1.0.0" -owlry_version = ">=0.3.0" -"#; - let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); - - assert!(check_compatibility(&manifest, "0.3.5").is_ok()); - assert!(check_compatibility(&manifest, "0.2.0").is_err()); - } -} diff --git a/crates/owlry-core/src/plugins/manifest.rs b/crates/owlry-core/src/plugins/manifest.rs deleted file mode 100644 index 6972750..0000000 --- a/crates/owlry-core/src/plugins/manifest.rs +++ /dev/null @@ -1,429 +0,0 @@ -//! Plugin manifest (plugin.toml) parsing - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use super::error::{PluginError, PluginResult}; - -/// Plugin manifest loaded from plugin.toml -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginManifest { - pub plugin: PluginInfo, - /// Provider declarations from [[providers]] sections (new-style) - #[serde(default)] - pub providers: Vec, - /// Legacy provides block (old-style) - #[serde(default)] - pub provides: PluginProvides, - #[serde(default)] - pub permissions: PluginPermissions, - #[serde(default)] - pub settings: HashMap, -} - -/// Core plugin information -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginInfo { - /// Unique plugin identifier (lowercase, alphanumeric, hyphens) - pub id: String, - /// Human-readable name - pub name: String, - /// Semantic version - pub version: String, - /// Short description - #[serde(default)] - pub description: String, - /// Plugin author - #[serde(default)] - pub author: String, - /// License identifier - #[serde(default)] - pub license: String, - /// Repository URL - #[serde(default)] - pub repository: Option, - /// Required owlry version (semver constraint) - #[serde(default = "default_owlry_version")] - pub owlry_version: String, - /// Entry point file (relative to plugin directory) - #[serde(default = "default_entry", alias = "entry_point")] - pub entry: String, -} - -fn default_owlry_version() -> String { - ">=0.1.0".to_string() -} - -fn default_entry() -> String { - "main.lua".to_string() -} - -/// A provider declared in a [[providers]] section -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProviderSpec { - pub id: String, - pub name: String, - #[serde(default)] - pub prefix: Option, - #[serde(default)] - pub icon: Option, - /// "static" (default) or "dynamic" - #[serde(default = "default_provider_type", rename = "type")] - pub provider_type: String, - #[serde(default)] - pub type_id: Option, - /// Short label for UI tab button (e.g., "Shutdown"). Defaults to provider name. - #[serde(default)] - pub tab_label: Option, - /// Noun for search placeholder (e.g., "shutdown actions"). Defaults to provider name. - #[serde(default)] - pub search_noun: Option, -} - -fn default_provider_type() -> String { - "static".to_string() -} - -/// What the plugin provides -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct PluginProvides { - /// Provider names this plugin registers - #[serde(default)] - pub providers: Vec, - /// Whether this plugin registers actions - #[serde(default)] - pub actions: bool, - /// Theme names this plugin contributes - #[serde(default)] - pub themes: Vec, - /// Whether this plugin registers hooks - #[serde(default)] - pub hooks: bool, - /// CLI commands this plugin provides - #[serde(default)] - pub commands: Vec, -} - -/// A CLI command provided by a plugin -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginCommand { - /// Command name (e.g., "add", "list", "sync") - pub name: String, - /// Short description shown in help - #[serde(default)] - pub description: String, - /// Usage pattern (e.g., " [name]") - #[serde(default)] - pub usage: String, -} - -/// Plugin permissions/capabilities -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct PluginPermissions { - /// Allow network/HTTP requests - #[serde(default)] - pub network: bool, - /// Filesystem paths the plugin can access (beyond its own directory) - #[serde(default)] - pub filesystem: Vec, - /// Commands the plugin is allowed to run - #[serde(default)] - pub run_commands: Vec, - /// Environment variables the plugin reads - #[serde(default)] - pub environment: Vec, -} - -// ============================================================================ -// Plugin Discovery (no Lua dependency) -// ============================================================================ - -/// Discover all plugins in a directory -/// -/// Returns a map of plugin ID -> (manifest, path) -pub fn discover_plugins( - plugins_dir: &Path, -) -> PluginResult> { - let mut plugins = HashMap::new(); - - if !plugins_dir.exists() { - log::debug!( - "Plugins directory does not exist: {}", - plugins_dir.display() - ); - return Ok(plugins); - } - - let entries = std::fs::read_dir(plugins_dir)?; - - for entry in entries { - let entry = entry?; - let path = entry.path(); - - if !path.is_dir() { - continue; - } - - let manifest_path = path.join("plugin.toml"); - if !manifest_path.exists() { - log::debug!("Skipping {}: no plugin.toml", path.display()); - continue; - } - - match PluginManifest::load(&manifest_path) { - Ok(manifest) => { - let id = manifest.plugin.id.clone(); - if plugins.contains_key(&id) { - log::warn!("Duplicate plugin ID '{}', skipping {}", id, path.display()); - continue; - } - log::info!( - "Discovered plugin: {} v{}", - manifest.plugin.name, - manifest.plugin.version - ); - plugins.insert(id, (manifest, path)); - } - Err(e) => { - log::warn!("Failed to load plugin at {}: {}", path.display(), e); - } - } - } - - Ok(plugins) -} - -/// Check if a plugin is compatible with the given owlry version -#[allow(dead_code)] -pub fn check_compatibility(manifest: &PluginManifest, owlry_version: &str) -> PluginResult<()> { - if !manifest.is_compatible_with(owlry_version) { - return Err(PluginError::VersionMismatch { - plugin: manifest.plugin.id.clone(), - required: manifest.plugin.owlry_version.clone(), - current: owlry_version.to_string(), - }); - } - Ok(()) -} - -// ============================================================================ -// PluginManifest Implementation -// ============================================================================ - -impl PluginManifest { - /// Load a plugin manifest from a plugin.toml file - pub fn load(path: &Path) -> PluginResult { - let content = std::fs::read_to_string(path)?; - let manifest: PluginManifest = toml::from_str(&content)?; - manifest.validate()?; - Ok(manifest) - } - - /// Load from a plugin directory (looks for plugin.toml inside) - #[allow(dead_code)] - pub fn load_from_dir(plugin_dir: &Path) -> PluginResult { - let manifest_path = plugin_dir.join("plugin.toml"); - if !manifest_path.exists() { - return Err(PluginError::InvalidManifest { - plugin: plugin_dir.display().to_string(), - message: "plugin.toml not found".to_string(), - }); - } - Self::load(&manifest_path) - } - - /// Validate the manifest - fn validate(&self) -> PluginResult<()> { - // Validate plugin ID format - if self.plugin.id.is_empty() { - return Err(PluginError::InvalidManifest { - plugin: self.plugin.id.clone(), - message: "Plugin ID cannot be empty".to_string(), - }); - } - - if !self - .plugin - .id - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - { - return Err(PluginError::InvalidManifest { - plugin: self.plugin.id.clone(), - message: "Plugin ID must be lowercase alphanumeric with hyphens".to_string(), - }); - } - - // Validate version format - if semver::Version::parse(&self.plugin.version).is_err() { - return Err(PluginError::InvalidManifest { - plugin: self.plugin.id.clone(), - message: format!("Invalid version format: {}", self.plugin.version), - }); - } - - // Validate owlry_version constraint - if semver::VersionReq::parse(&self.plugin.owlry_version).is_err() { - return Err(PluginError::InvalidManifest { - plugin: self.plugin.id.clone(), - message: format!( - "Invalid owlry_version constraint: {}", - self.plugin.owlry_version - ), - }); - } - - Ok(()) - } - - /// Check if this plugin is compatible with the given owlry version - #[allow(dead_code)] - pub fn is_compatible_with(&self, owlry_version: &str) -> bool { - let req = match semver::VersionReq::parse(&self.plugin.owlry_version) { - Ok(r) => r, - Err(_) => return false, - }; - let version = match semver::Version::parse(owlry_version) { - Ok(v) => v, - Err(_) => return false, - }; - req.matches(&version) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_minimal_manifest() { - let toml_str = r#" -[plugin] -id = "test-plugin" -name = "Test Plugin" -version = "1.0.0" -"#; - let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); - assert_eq!(manifest.plugin.id, "test-plugin"); - assert_eq!(manifest.plugin.name, "Test Plugin"); - assert_eq!(manifest.plugin.version, "1.0.0"); - assert_eq!(manifest.plugin.entry, "main.lua"); - } - - #[test] - fn test_parse_full_manifest() { - let toml_str = r#" -[plugin] -id = "my-provider" -name = "My Provider" -version = "1.2.3" -description = "A test provider" -author = "Test Author" -license = "MIT" -owlry_version = ">=0.4.0" -entry = "main.lua" - -[provides] -providers = ["my-provider"] -actions = true -themes = ["dark"] -hooks = true - -[permissions] -network = true -filesystem = ["~/.config/myapp"] -run_commands = ["myapp"] -environment = ["MY_API_KEY"] - -[settings] -max_results = 20 -api_url = "https://api.example.com" -"#; - let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); - assert_eq!(manifest.plugin.id, "my-provider"); - assert!(manifest.provides.actions); - assert!(manifest.permissions.network); - assert_eq!(manifest.permissions.run_commands, vec!["myapp"]); - } - - #[test] - fn test_parse_new_format_with_providers_array() { - let toml_str = r#" -[plugin] -id = "my-plugin" -name = "My Plugin" -version = "0.1.0" -description = "Test" -entry_point = "main.rn" - -[[providers]] -id = "my-plugin" -name = "My Plugin" -type = "static" -type_id = "myplugin" -icon = "system-run" -prefix = ":mp" -"#; - let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); - assert_eq!(manifest.plugin.entry, "main.rn"); - assert_eq!(manifest.providers.len(), 1); - let p = &manifest.providers[0]; - assert_eq!(p.id, "my-plugin"); - assert_eq!(p.name, "My Plugin"); - assert_eq!(p.provider_type, "static"); - assert_eq!(p.type_id.as_deref(), Some("myplugin")); - assert_eq!(p.icon.as_deref(), Some("system-run")); - assert_eq!(p.prefix.as_deref(), Some(":mp")); - } - - #[test] - fn test_parse_new_format_entry_point_alias() { - let toml_str = r#" -[plugin] -id = "test" -name = "Test" -version = "1.0.0" -entry_point = "main.lua" -"#; - let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); - assert_eq!(manifest.plugin.entry, "main.lua"); - } - - #[test] - fn test_provider_spec_defaults() { - let toml_str = r#" -[plugin] -id = "test" -name = "Test" -version = "1.0.0" - -[[providers]] -id = "test" -name = "Test" -"#; - let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); - assert_eq!(manifest.providers.len(), 1); - let p = &manifest.providers[0]; - assert_eq!(p.provider_type, "static"); // default - assert!(p.prefix.is_none()); - assert!(p.icon.is_none()); - assert!(p.type_id.is_none()); - } - - #[test] - fn test_version_compatibility() { - let toml_str = r#" -[plugin] -id = "test" -name = "Test" -version = "1.0.0" -owlry_version = ">=0.3.0, <1.0.0" -"#; - let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); - assert!(manifest.is_compatible_with("0.3.5")); - assert!(manifest.is_compatible_with("0.4.0")); - assert!(!manifest.is_compatible_with("0.2.0")); - assert!(!manifest.is_compatible_with("1.0.0")); - } -} diff --git a/crates/owlry-core/src/plugins/mod.rs b/crates/owlry-core/src/plugins/mod.rs deleted file mode 100644 index 7dcd8d6..0000000 --- a/crates/owlry-core/src/plugins/mod.rs +++ /dev/null @@ -1,368 +0,0 @@ -//! Owlry Plugin System -//! -//! This module loads and manages *plugins* — external code that extends owlry -//! with additional *plugin providers* beyond the built-in ones. -//! -//! # Terminology -//! -//! | Term | Meaning | -//! |------|---------| -//! | **Provider** | Abstract source of [`LaunchItem`]s (the core interface) | -//! | **Built-in provider** | Provider compiled into owlry-core (Application, Command) | -//! | **Plugin** | External code (native `.so` or script) loaded at runtime | -//! | **Plugin provider** | A provider registered by a plugin via its `type_id` | -//! | **Native plugin** | Pre-compiled Rust `.so` from `/usr/lib/owlry/plugins/` | -//! | **Script plugin** | Lua or Rune plugin from `~/.config/owlry/plugins/` | -//! -//! # Plugin Types -//! -//! - **Native plugins** (`.so`): Pre-compiled Rust plugins loaded from `/usr/lib/owlry/plugins/` -//! - **Script plugins**: Lua or Rune scripts from `~/.config/owlry/plugins/` -//! (requires the corresponding runtime: `owlry-lua` or `owlry-rune`) -//! -//! # Script Plugin Structure -//! -//! Each script plugin lives in its own directory under `~/.config/owlry/plugins/`: -//! -//! ```text -//! ~/.config/owlry/plugins/ -//! my-plugin/ -//! plugin.toml # Plugin manifest -//! init.lua # Entry point (Lua) or init.rn (Rune) -//! lib/ # Optional modules -//! ``` -//! -//! [`LaunchItem`]: crate::providers::LaunchItem - -// Always available -pub mod error; -pub mod manifest; -pub mod native_loader; -pub mod registry; -pub mod runtime_loader; -pub mod watcher; - -// Lua-specific modules (require mlua) -#[cfg(feature = "lua")] -pub mod api; -#[cfg(feature = "lua")] -pub mod loader; -#[cfg(feature = "lua")] -pub mod runtime; - -// Re-export commonly used types -#[cfg(feature = "lua")] -pub use api::provider::{PluginItem, ProviderRegistration}; -#[cfg(feature = "lua")] -#[allow(unused_imports)] -pub use api::{ActionRegistration, HookEvent, ThemeRegistration}; - -#[allow(unused_imports)] -pub use error::{PluginError, PluginResult}; - -#[cfg(feature = "lua")] -pub use loader::LoadedPlugin; - -// Used by plugins/commands.rs for plugin CLI commands -#[allow(unused_imports)] -pub use manifest::{PluginManifest, check_compatibility, discover_plugins}; - -// ============================================================================ -// Lua Plugin Manager (only available with lua feature) -// ============================================================================ - -#[cfg(feature = "lua")] -mod lua_manager { - use super::*; - use std::collections::HashMap; - use std::path::PathBuf; - use std::sync::{Arc, Mutex}; - - use manifest::{check_compatibility, discover_plugins}; - - /// Plugin manager coordinates loading, initialization, and lifecycle of Lua plugins - pub struct PluginManager { - /// Directory where plugins are stored - plugins_dir: PathBuf, - /// Current owlry version for compatibility checks - owlry_version: String, - /// Loaded plugins by ID. Arc> allows sharing with LuaProviders across threads. - plugins: HashMap>>, - /// Plugin IDs that are explicitly disabled - disabled: Vec, - } - - impl PluginManager { - /// Create a new plugin manager - pub fn new(plugins_dir: PathBuf, owlry_version: &str) -> Self { - Self { - plugins_dir, - owlry_version: owlry_version.to_string(), - plugins: HashMap::new(), - disabled: Vec::new(), - } - } - - /// Set the list of disabled plugin IDs - pub fn set_disabled(&mut self, disabled: Vec) { - self.disabled = disabled; - } - - /// Discover and load all plugins from the plugins directory - pub fn discover(&mut self) -> PluginResult { - log::info!("Discovering plugins in {}", self.plugins_dir.display()); - - let discovered = discover_plugins(&self.plugins_dir)?; - let mut loaded_count = 0; - - for (id, (manifest, path)) in discovered { - // Skip disabled plugins - if self.disabled.contains(&id) { - log::info!("Plugin '{}' is disabled, skipping", id); - continue; - } - - // Check version compatibility - if let Err(e) = check_compatibility(&manifest, &self.owlry_version) { - log::warn!("Plugin '{}' is not compatible: {}", id, e); - continue; - } - - let plugin = LoadedPlugin::new(manifest, path); - self.plugins.insert(id, Arc::new(Mutex::new(plugin))); - loaded_count += 1; - } - - log::info!("Discovered {} compatible plugins", loaded_count); - Ok(loaded_count) - } - - /// Initialize all discovered plugins (load their Lua code) - pub fn initialize_all(&mut self) -> Vec { - let mut errors = Vec::new(); - - for (id, plugin_rc) in &self.plugins { - let mut plugin = plugin_rc.lock().unwrap(); - if !plugin.enabled { - continue; - } - - log::debug!("Initializing plugin: {}", id); - if let Err(e) = plugin.initialize() { - log::error!("Failed to initialize plugin '{}': {}", id, e); - errors.push(e); - plugin.enabled = false; - } - } - - errors - } - - /// Get a loaded plugin by ID - #[allow(dead_code)] - pub fn get(&self, id: &str) -> Option>> { - self.plugins.get(id).cloned() - } - - /// Get all loaded plugins - #[allow(dead_code)] - pub fn plugins(&self) -> impl Iterator>> + '_ { - self.plugins.values().cloned() - } - - /// Get all enabled plugins - pub fn enabled_plugins(&self) -> impl Iterator>> + '_ { - self.plugins - .values() - .filter(|p| p.lock().unwrap().enabled) - .cloned() - } - - /// Get the number of loaded plugins - #[allow(dead_code)] - pub fn plugin_count(&self) -> usize { - self.plugins.len() - } - - /// Get the number of enabled plugins - #[allow(dead_code)] - pub fn enabled_count(&self) -> usize { - self.plugins.values().filter(|p| p.lock().unwrap().enabled).count() - } - - /// Enable a plugin by ID - #[allow(dead_code)] - pub fn enable(&mut self, id: &str) -> PluginResult<()> { - let plugin_rc = self - .plugins - .get(id) - .ok_or_else(|| PluginError::NotFound(id.to_string()))?; - let mut plugin = plugin_rc.lock().unwrap(); - - if !plugin.enabled { - plugin.enabled = true; - // Initialize if not already done - plugin.initialize()?; - } - - Ok(()) - } - - /// Disable a plugin by ID - #[allow(dead_code)] - pub fn disable(&mut self, id: &str) -> PluginResult<()> { - let plugin_rc = self - .plugins - .get(id) - .ok_or_else(|| PluginError::NotFound(id.to_string()))?; - plugin_rc.lock().unwrap().enabled = false; - Ok(()) - } - - /// Get plugin IDs that provide a specific feature - #[allow(dead_code)] - pub fn providers_for(&self, provider_name: &str) -> Vec { - self.enabled_plugins() - .filter(|p| { - p.lock() - .unwrap() - .manifest - .provides - .providers - .contains(&provider_name.to_string()) - }) - .map(|p| p.lock().unwrap().id().to_string()) - .collect() - } - - /// Check if any plugin provides actions - #[allow(dead_code)] - pub fn has_action_plugins(&self) -> bool { - self.enabled_plugins() - .any(|p| p.lock().unwrap().manifest.provides.actions) - } - - /// Check if any plugin provides hooks - #[allow(dead_code)] - pub fn has_hook_plugins(&self) -> bool { - self.enabled_plugins() - .any(|p| p.lock().unwrap().manifest.provides.hooks) - } - - /// Get all theme names provided by plugins - #[allow(dead_code)] - pub fn theme_names(&self) -> Vec { - self.enabled_plugins() - .flat_map(|p| p.lock().unwrap().manifest.provides.themes.clone()) - .collect() - } - - /// Create providers from all enabled plugins - /// - /// This must be called after `initialize_all()`. Returns a vec of Provider trait - /// objects that can be added to the ProviderManager. - pub fn create_providers(&self) -> Vec> { - use crate::providers::lua_provider::create_providers_from_plugin; - - let mut providers = Vec::new(); - - for plugin_rc in self.enabled_plugins() { - let plugin_providers = create_providers_from_plugin(plugin_rc); - providers.extend(plugin_providers); - } - - providers - } - } -} - -#[cfg(feature = "lua")] -pub use lua_manager::PluginManager; - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(all(test, feature = "lua"))] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - fn create_test_plugin(dir: &std::path::Path, id: &str, version: &str, owlry_req: &str) { - let plugin_dir = dir.join(id); - fs::create_dir_all(&plugin_dir).unwrap(); - - let manifest = format!( - r#" -[plugin] -id = "{}" -name = "Test {}" -version = "{}" -owlry_version = "{}" - -[provides] -providers = ["{}"] -"#, - id, id, version, owlry_req, id - ); - fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); - fs::write(plugin_dir.join("init.lua"), "-- test plugin").unwrap(); - } - - #[test] - fn test_plugin_manager_discover() { - let temp = TempDir::new().unwrap(); - create_test_plugin(temp.path(), "plugin-a", "1.0.0", ">=0.3.0"); - create_test_plugin(temp.path(), "plugin-b", "2.0.0", ">=0.3.0"); - - let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5"); - let count = manager.discover().unwrap(); - - assert_eq!(count, 2); - assert!(manager.get("plugin-a").is_some()); - assert!(manager.get("plugin-b").is_some()); - } - - #[test] - fn test_plugin_manager_disabled() { - let temp = TempDir::new().unwrap(); - create_test_plugin(temp.path(), "plugin-a", "1.0.0", ">=0.3.0"); - create_test_plugin(temp.path(), "plugin-b", "1.0.0", ">=0.3.0"); - - let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5"); - manager.set_disabled(vec!["plugin-b".to_string()]); - let count = manager.discover().unwrap(); - - assert_eq!(count, 1); - assert!(manager.get("plugin-a").is_some()); - assert!(manager.get("plugin-b").is_none()); - } - - #[test] - fn test_plugin_manager_version_compat() { - let temp = TempDir::new().unwrap(); - create_test_plugin(temp.path(), "old-plugin", "1.0.0", ">=0.5.0"); // Requires future version - create_test_plugin(temp.path(), "new-plugin", "1.0.0", ">=0.3.0"); - - let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5"); - let count = manager.discover().unwrap(); - - assert_eq!(count, 1); - assert!(manager.get("old-plugin").is_none()); // Incompatible - assert!(manager.get("new-plugin").is_some()); - } - - #[test] - fn test_providers_for() { - let temp = TempDir::new().unwrap(); - create_test_plugin(temp.path(), "my-provider", "1.0.0", ">=0.3.0"); - - let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5"); - manager.discover().unwrap(); - - let providers = manager.providers_for("my-provider"); - assert_eq!(providers.len(), 1); - assert_eq!(providers[0], "my-provider"); - } -} diff --git a/crates/owlry-core/src/plugins/native_loader.rs b/crates/owlry-core/src/plugins/native_loader.rs deleted file mode 100644 index f9431bd..0000000 --- a/crates/owlry-core/src/plugins/native_loader.rs +++ /dev/null @@ -1,458 +0,0 @@ -//! Native Plugin Loader -//! -//! Loads pre-compiled Rust plugins (.so files) from `/usr/lib/owlry/plugins/`. -//! These plugins use the ABI-stable interface defined in `owlry-plugin-api`. -//! -//! Note: This module is infrastructure for the plugin architecture. Full integration -//! with ProviderManager is pending Phase 5 (AUR Packaging) when native plugins -//! will actually be deployed. - -#![allow(dead_code)] - -use std::collections::HashMap; -use std::ffi::OsStr; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Once, OnceLock, RwLock}; - -use libloading::Library; -use log::{debug, error, info, warn}; -use owlry_plugin_api::{ - API_VERSION, HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo, - ProviderKind, ROption, RStr, RString, -}; - -use crate::config::Config; -use crate::notify; - -// ============================================================================ -// Plugin config access -// ============================================================================ - -/// Shared config reference, set by the host before any plugins are loaded. -static PLUGIN_CONFIG: OnceLock>> = OnceLock::new(); - -/// Share the config with the native plugin loader so plugins can read their -/// own config sections. Must be called before `NativePluginLoader::discover()`. -pub fn set_shared_config(config: Arc>) { - let _ = PLUGIN_CONFIG.set(config); -} - -extern "C" fn host_get_config_string(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption { - let Some(cfg_arc) = PLUGIN_CONFIG.get() else { - return ROption::RNone; - }; - let Ok(cfg) = cfg_arc.read() else { - return ROption::RNone; - }; - match cfg.get_plugin_string(plugin_id.as_str(), key.as_str()) { - Some(v) => ROption::RSome(RString::from(v)), - None => ROption::RNone, - } -} - -extern "C" fn host_get_config_int(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption { - let Some(cfg_arc) = PLUGIN_CONFIG.get() else { - return ROption::RNone; - }; - let Ok(cfg) = cfg_arc.read() else { - return ROption::RNone; - }; - match cfg.get_plugin_int(plugin_id.as_str(), key.as_str()) { - Some(v) => ROption::RSome(v), - None => ROption::RNone, - } -} - -extern "C" fn host_get_config_bool(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption { - let Some(cfg_arc) = PLUGIN_CONFIG.get() else { - return ROption::RNone; - }; - let Ok(cfg) = cfg_arc.read() else { - return ROption::RNone; - }; - match cfg.get_plugin_bool(plugin_id.as_str(), key.as_str()) { - Some(v) => ROption::RSome(v), - None => ROption::RNone, - } -} - -// ============================================================================ -// Host API Implementation -// ============================================================================ - -/// Host notification handler -extern "C" fn host_notify( - summary: RStr<'_>, - body: RStr<'_>, - icon: RStr<'_>, - urgency: NotifyUrgency, -) { - let icon_str = icon.as_str(); - let icon_opt = if icon_str.is_empty() { - None - } else { - Some(icon_str) - }; - - let notify_urgency = match urgency { - NotifyUrgency::Low => notify::NotifyUrgency::Low, - NotifyUrgency::Normal => notify::NotifyUrgency::Normal, - NotifyUrgency::Critical => notify::NotifyUrgency::Critical, - }; - - notify::notify_with_options(summary.as_str(), body.as_str(), icon_opt, notify_urgency); -} - -/// Host log info handler -extern "C" fn host_log_info(message: RStr<'_>) { - info!("[plugin] {}", message.as_str()); -} - -/// Host log warning handler -extern "C" fn host_log_warn(message: RStr<'_>) { - warn!("[plugin] {}", message.as_str()); -} - -/// Host log error handler -extern "C" fn host_log_error(message: RStr<'_>) { - error!("[plugin] {}", message.as_str()); -} - -/// Static host API instance -static HOST_API: HostAPI = HostAPI { - notify: host_notify, - log_info: host_log_info, - log_warn: host_log_warn, - log_error: host_log_error, - get_config_string: host_get_config_string, - get_config_int: host_get_config_int, - get_config_bool: host_get_config_bool, -}; - -/// Initialize the host API (called once before loading plugins) -static HOST_API_INIT: Once = Once::new(); - -fn ensure_host_api_initialized() { - HOST_API_INIT.call_once(|| { - // SAFETY: We only call this once, before any plugins are loaded - unsafe { - owlry_plugin_api::init_host_api(&HOST_API); - } - debug!("Host API initialized for plugins"); - }); -} - -use super::error::{PluginError, PluginResult}; - -/// Default directory for system-installed native plugins -pub const SYSTEM_PLUGINS_DIR: &str = "/usr/lib/owlry/plugins"; - -/// A loaded native plugin with its library handle and vtable -pub struct NativePlugin { - /// Plugin metadata - pub info: PluginInfo, - /// List of providers this plugin offers - pub providers: Vec, - /// The vtable for calling plugin functions - vtable: &'static PluginVTable, - /// The loaded library (must be kept alive) - _library: Library, -} - -impl NativePlugin { - /// Get the plugin ID - pub fn id(&self) -> &str { - self.info.id.as_str() - } - - /// Get the plugin name - pub fn name(&self) -> &str { - self.info.name.as_str() - } - - /// Initialize a provider by ID - pub fn init_provider(&self, provider_id: &str) -> ProviderHandle { - (self.vtable.provider_init)(provider_id.into()) - } - - /// Refresh a static provider - pub fn refresh_provider(&self, handle: ProviderHandle) -> Vec { - (self.vtable.provider_refresh)(handle).into_iter().collect() - } - - /// Query a dynamic provider - pub fn query_provider( - &self, - handle: ProviderHandle, - query: &str, - ) -> Vec { - (self.vtable.provider_query)(handle, query.into()) - .into_iter() - .collect() - } - - /// Drop a provider handle - pub fn drop_provider(&self, handle: ProviderHandle) { - (self.vtable.provider_drop)(handle) - } -} - -// SAFETY: NativePlugin is safe to send between threads because: -// - `info` and `providers` are plain data (RString, RVec from abi_stable are Send+Sync) -// - `vtable` is a &'static reference to immutable function pointers -// - `_library` (libloading::Library) is Send+Sync -unsafe impl Send for NativePlugin {} -unsafe impl Sync for NativePlugin {} - -/// Manages native plugin discovery and loading -pub struct NativePluginLoader { - /// Directory to scan for plugins - plugins_dir: PathBuf, - /// Loaded plugins by ID (Arc for shared ownership with providers) - plugins: HashMap>, - /// Plugin IDs that are disabled - disabled: Vec, -} - -impl NativePluginLoader { - /// Create a new loader with the default system plugins directory - pub fn new() -> Self { - Self::with_dir(PathBuf::from(SYSTEM_PLUGINS_DIR)) - } - - /// Create a new loader with a custom plugins directory - pub fn with_dir(plugins_dir: PathBuf) -> Self { - Self { - plugins_dir, - plugins: HashMap::new(), - disabled: Vec::new(), - } - } - - /// Set the list of disabled plugin IDs - pub fn set_disabled(&mut self, disabled: Vec) { - self.disabled = disabled; - } - - /// Check if the plugins directory exists - pub fn plugins_dir_exists(&self) -> bool { - self.plugins_dir.exists() - } - - /// Discover and load all native plugins - pub fn discover(&mut self) -> PluginResult { - // Initialize host API before loading any plugins - ensure_host_api_initialized(); - - if !self.plugins_dir.exists() { - debug!( - "Native plugins directory does not exist: {}", - self.plugins_dir.display() - ); - return Ok(0); - } - - info!( - "Discovering native plugins in {}", - self.plugins_dir.display() - ); - - let entries = std::fs::read_dir(&self.plugins_dir).map_err(|e| { - PluginError::LoadError(format!( - "Failed to read plugins directory {}: {}", - self.plugins_dir.display(), - e - )) - })?; - - let mut loaded_count = 0; - - for entry in entries.flatten() { - let path = entry.path(); - - // Only process .so files - if path.extension() != Some(OsStr::new("so")) { - continue; - } - - match self.load_plugin(&path) { - Ok(plugin) => { - let id = plugin.id().to_string(); - - // Check if disabled - if self.disabled.contains(&id) { - info!("Native plugin '{}' is disabled, skipping", id); - continue; - } - - info!( - "Loaded native plugin '{}' v{} with {} providers", - plugin.name(), - plugin.info.version.as_str(), - plugin.providers.len() - ); - - self.plugins.insert(id, Arc::new(plugin)); - loaded_count += 1; - } - Err(e) => { - error!("Failed to load plugin {:?}: {}", path, e); - } - } - } - - info!("Loaded {} native plugins", loaded_count); - Ok(loaded_count) - } - - /// Load a single plugin from a .so file - fn load_plugin(&self, path: &Path) -> PluginResult { - debug!("Loading native plugin from {:?}", path); - - // Load the library - // SAFETY: We trust plugins in /usr/lib/owlry/plugins/ as they were - // installed by the package manager - let library = unsafe { Library::new(path) }.map_err(|e| { - PluginError::LoadError(format!("Failed to load library {:?}: {}", path, e)) - })?; - - // Get the vtable function - let vtable: &'static PluginVTable = unsafe { - let func: libloading::Symbol &'static PluginVTable> = - library.get(b"owlry_plugin_vtable").map_err(|e| { - PluginError::LoadError(format!( - "Plugin {:?} missing owlry_plugin_vtable symbol: {}", - path, e - )) - })?; - func() - }; - - // Get plugin info - let info = (vtable.info)(); - - // Check API version compatibility - if info.api_version != API_VERSION { - return Err(PluginError::LoadError(format!( - "Plugin '{}' has API version {} but owlry requires version {}", - info.id.as_str(), - info.api_version, - API_VERSION - ))); - } - - // Get provider list - let providers: Vec = (vtable.providers)().into_iter().collect(); - - Ok(NativePlugin { - info, - providers, - vtable, - _library: library, - }) - } - - /// Get a loaded plugin by ID - pub fn get(&self, id: &str) -> Option> { - self.plugins.get(id).cloned() - } - - /// Get all loaded plugins as Arc references - pub fn plugins(&self) -> impl Iterator> + '_ { - self.plugins.values().cloned() - } - - /// Get all loaded plugins as a Vec (for passing to create_providers) - pub fn into_plugins(self) -> Vec> { - self.plugins.into_values().collect() - } - - /// Get the number of loaded plugins - pub fn plugin_count(&self) -> usize { - self.plugins.len() - } - - /// Create providers from all loaded native plugins - /// - /// Returns a vec of (plugin_id, provider_info, handle) tuples that can be - /// used to create NativeProvider instances. - pub fn create_provider_handles(&self) -> Vec<(String, ProviderInfo, ProviderHandle)> { - let mut handles = Vec::new(); - - for plugin in self.plugins.values() { - for provider_info in &plugin.providers { - let handle = plugin.init_provider(provider_info.id.as_str()); - handles.push((plugin.id().to_string(), provider_info.clone(), handle)); - } - } - - handles - } -} - -impl Default for NativePluginLoader { - fn default() -> Self { - Self::new() - } -} - -/// Active provider instance from a native plugin -pub struct NativeProviderInstance { - /// Plugin ID this provider belongs to - pub plugin_id: String, - /// Provider metadata - pub info: ProviderInfo, - /// Handle to the provider state - pub handle: ProviderHandle, - /// Cached items for static providers - pub cached_items: Vec, -} - -impl NativeProviderInstance { - /// Create a new provider instance - pub fn new(plugin_id: String, info: ProviderInfo, handle: ProviderHandle) -> Self { - Self { - plugin_id, - info, - handle, - cached_items: Vec::new(), - } - } - - /// Check if this is a static provider - pub fn is_static(&self) -> bool { - self.info.provider_type == ProviderKind::Static - } - - /// Check if this is a dynamic provider - pub fn is_dynamic(&self) -> bool { - self.info.provider_type == ProviderKind::Dynamic - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_loader_nonexistent_dir() { - let mut loader = NativePluginLoader::with_dir(PathBuf::from("/nonexistent/path")); - let count = loader.discover().unwrap(); - assert_eq!(count, 0); - } - - #[test] - fn test_loader_empty_dir() { - let temp = tempfile::TempDir::new().unwrap(); - let mut loader = NativePluginLoader::with_dir(temp.path().to_path_buf()); - let count = loader.discover().unwrap(); - assert_eq!(count, 0); - } - - #[test] - fn test_disabled_plugins() { - let mut loader = NativePluginLoader::new(); - loader.set_disabled(vec!["test-plugin".to_string()]); - assert!(loader.disabled.contains(&"test-plugin".to_string())); - } -} diff --git a/crates/owlry-core/src/plugins/registry.rs b/crates/owlry-core/src/plugins/registry.rs deleted file mode 100644 index 2d25306..0000000 --- a/crates/owlry-core/src/plugins/registry.rs +++ /dev/null @@ -1,292 +0,0 @@ -//! Plugin registry client for discovering and installing remote plugins -//! -//! The registry is a git repository containing an `index.toml` file with -//! plugin metadata. Plugins are installed by cloning their source repositories. - -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{Duration, SystemTime}; - -use crate::paths; - -/// Default registry URL (can be overridden in config) -pub const DEFAULT_REGISTRY_URL: &str = - "https://raw.githubusercontent.com/owlry/plugin-registry/main/index.toml"; - -/// Cache duration for registry index (1 hour) -const CACHE_DURATION: Duration = Duration::from_secs(3600); - -/// Registry index containing all available plugins -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RegistryIndex { - /// Registry metadata - #[serde(default)] - pub registry: RegistryMeta, - /// Available plugins - #[serde(default)] - pub plugins: Vec, -} - -/// Registry metadata -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct RegistryMeta { - /// Registry name - #[serde(default)] - pub name: String, - /// Registry description - #[serde(default)] - pub description: String, - /// Registry maintainer URL - #[serde(default)] - pub url: String, -} - -/// Plugin entry in the registry -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RegistryPlugin { - /// Unique plugin identifier - pub id: String, - /// Human-readable name - pub name: String, - /// Latest version - pub version: String, - /// Short description - #[serde(default)] - pub description: String, - /// Plugin author - #[serde(default)] - pub author: String, - /// Git repository URL for installation - pub repository: String, - /// Search tags - #[serde(default)] - pub tags: Vec, - /// Minimum owlry version required - #[serde(default)] - pub owlry_version: String, - /// License identifier - #[serde(default)] - pub license: String, -} - -/// Registry client for fetching and searching plugins -pub struct RegistryClient { - /// Registry URL (index.toml location) - registry_url: String, - /// Local cache directory - cache_dir: PathBuf, -} - -impl RegistryClient { - /// Create a new registry client with the given URL - pub fn new(registry_url: &str) -> Self { - let cache_dir = paths::owlry_cache_dir() - .unwrap_or_else(|| PathBuf::from("/tmp/owlry")) - .join("registry"); - - Self { - registry_url: registry_url.to_string(), - cache_dir, - } - } - - /// Create a client with the default registry URL - pub fn default_registry() -> Self { - Self::new(DEFAULT_REGISTRY_URL) - } - - /// Get the path to the cached index file - fn cache_path(&self) -> PathBuf { - self.cache_dir.join("index.toml") - } - - /// Check if the cache is valid (exists and not expired) - fn is_cache_valid(&self) -> bool { - let cache_path = self.cache_path(); - if !cache_path.exists() { - return false; - } - - if let Ok(metadata) = fs::metadata(&cache_path) - && let Ok(modified) = metadata.modified() - && let Ok(elapsed) = SystemTime::now().duration_since(modified) - { - return elapsed < CACHE_DURATION; - } - - false - } - - /// Fetch the registry index (from cache or network) - pub fn fetch_index(&self, force_refresh: bool) -> Result { - // Use cache if valid and not forcing refresh - if !force_refresh - && self.is_cache_valid() - && let Ok(content) = fs::read_to_string(self.cache_path()) - && let Ok(index) = toml::from_str(&content) - { - return Ok(index); - } - - // Fetch from network - self.fetch_from_network() - } - - /// Fetch the index from the network and cache it - fn fetch_from_network(&self) -> Result { - // Use curl for fetching (available on most systems) - let output = std::process::Command::new("curl") - .args(["-fsSL", "--max-time", "30", &self.registry_url]) - .output() - .map_err(|e| format!("Failed to run curl: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("Failed to fetch registry: {}", stderr.trim())); - } - - let content = String::from_utf8_lossy(&output.stdout); - - // Parse the index - let index: RegistryIndex = toml::from_str(&content) - .map_err(|e| format!("Failed to parse registry index: {}", e))?; - - // Cache the result - if let Err(e) = self.cache_index(&content) { - eprintln!("Warning: Failed to cache registry index: {}", e); - } - - Ok(index) - } - - /// Cache the index content to disk - fn cache_index(&self, content: &str) -> Result<(), String> { - fs::create_dir_all(&self.cache_dir) - .map_err(|e| format!("Failed to create cache directory: {}", e))?; - - fs::write(self.cache_path(), content) - .map_err(|e| format!("Failed to write cache file: {}", e))?; - - Ok(()) - } - - /// Search for plugins matching a query - pub fn search(&self, query: &str, force_refresh: bool) -> Result, String> { - let index = self.fetch_index(force_refresh)?; - let query_lower = query.to_lowercase(); - - let matches: Vec<_> = index - .plugins - .into_iter() - .filter(|p| { - p.id.to_lowercase().contains(&query_lower) - || p.name.to_lowercase().contains(&query_lower) - || p.description.to_lowercase().contains(&query_lower) - || p.tags - .iter() - .any(|t| t.to_lowercase().contains(&query_lower)) - }) - .collect(); - - Ok(matches) - } - - /// Find a specific plugin by ID - pub fn find(&self, id: &str, force_refresh: bool) -> Result, String> { - let index = self.fetch_index(force_refresh)?; - - Ok(index.plugins.into_iter().find(|p| p.id == id)) - } - - /// List all available plugins - pub fn list_all(&self, force_refresh: bool) -> Result, String> { - let index = self.fetch_index(force_refresh)?; - Ok(index.plugins) - } - - /// Clear the cache - #[allow(dead_code)] - pub fn clear_cache(&self) -> Result<(), String> { - let cache_path = self.cache_path(); - if cache_path.exists() { - fs::remove_file(&cache_path).map_err(|e| format!("Failed to remove cache: {}", e))?; - } - Ok(()) - } - - /// Get the repository URL for a plugin - #[allow(dead_code)] - pub fn get_install_url(&self, id: &str) -> Result { - match self.find(id, false)? { - Some(plugin) => Ok(plugin.repository), - None => Err(format!("Plugin '{}' not found in registry", id)), - } - } -} - -/// Check if a string looks like a URL (for distinguishing registry names from URLs) -pub fn is_url(s: &str) -> bool { - s.starts_with("http://") - || s.starts_with("https://") - || s.starts_with("git@") - || s.starts_with("git://") -} - -/// Check if a string looks like a local path -pub fn is_path(s: &str) -> bool { - s.starts_with('/') - || s.starts_with("./") - || s.starts_with("../") - || s.starts_with('~') - || Path::new(s).exists() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_registry_index() { - let toml_str = r#" -[registry] -name = "Test Registry" -description = "A test registry" - -[[plugins]] -id = "test-plugin" -name = "Test Plugin" -version = "1.0.0" -description = "A test plugin" -author = "Test Author" -repository = "https://github.com/test/plugin" -tags = ["test", "example"] -owlry_version = ">=0.3.0" -"#; - - let index: RegistryIndex = toml::from_str(toml_str).unwrap(); - assert_eq!(index.registry.name, "Test Registry"); - assert_eq!(index.plugins.len(), 1); - assert_eq!(index.plugins[0].id, "test-plugin"); - assert_eq!(index.plugins[0].tags, vec!["test", "example"]); - } - - #[test] - fn test_is_url() { - assert!(is_url("https://github.com/user/repo")); - assert!(is_url("http://example.com")); - assert!(is_url("git@github.com:user/repo.git")); - assert!(!is_url("my-plugin")); - assert!(!is_url("/path/to/plugin")); - } - - #[test] - fn test_is_path() { - assert!(is_path("/absolute/path")); - assert!(is_path("./relative/path")); - assert!(is_path("../parent/path")); - assert!(is_path("~/home/path")); - assert!(!is_path("my-plugin")); - assert!(!is_path("https://example.com")); - } -} diff --git a/crates/owlry-core/src/plugins/runtime.rs b/crates/owlry-core/src/plugins/runtime.rs deleted file mode 100644 index 00ebf57..0000000 --- a/crates/owlry-core/src/plugins/runtime.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! Lua runtime setup and sandboxing - -use mlua::{Lua, Result as LuaResult, StdLib}; - -use super::manifest::PluginPermissions; - -/// Configuration for the Lua sandbox -#[derive(Debug, Clone)] -#[allow(dead_code)] // Fields used for future permission enforcement -pub struct SandboxConfig { - /// Allow shell command running - pub allow_commands: bool, - /// Allow HTTP requests - pub allow_network: bool, - /// Allow filesystem access outside plugin directory - pub allow_external_fs: bool, - /// Maximum run time per call (ms) - pub max_run_time_ms: u64, - /// Memory limit (bytes, 0 = unlimited) - pub max_memory: usize, -} - -impl Default for SandboxConfig { - fn default() -> Self { - Self { - allow_commands: false, - allow_network: false, - allow_external_fs: false, - max_run_time_ms: 5000, // 5 seconds - max_memory: 64 * 1024 * 1024, // 64 MB - } - } -} - -impl SandboxConfig { - /// Create a sandbox config from plugin permissions - pub fn from_permissions(permissions: &PluginPermissions) -> Self { - Self { - allow_commands: !permissions.run_commands.is_empty(), - allow_network: permissions.network, - allow_external_fs: !permissions.filesystem.is_empty(), - ..Default::default() - } - } -} - -/// Create a new sandboxed Lua runtime -pub fn create_lua_runtime(_sandbox: &SandboxConfig) -> LuaResult { - // Create Lua with safe standard libraries only - // ALL_SAFE excludes: debug, io, os (dangerous parts), package (loadlib), ffi - // We then customize the os table to only allow safe functions - let libs = StdLib::COROUTINE | StdLib::TABLE | StdLib::STRING | StdLib::UTF8 | StdLib::MATH; - - let lua = Lua::new_with(libs, mlua::LuaOptions::default())?; - - // Set up safe environment - setup_safe_globals(&lua)?; - - Ok(lua) -} - -/// Set up safe global environment by removing/replacing dangerous functions -fn setup_safe_globals(lua: &Lua) -> LuaResult<()> { - let globals = lua.globals(); - - // Remove dangerous globals - globals.set("dofile", mlua::Value::Nil)?; - globals.set("loadfile", mlua::Value::Nil)?; - - // Create a restricted os table with only safe functions - // We do NOT include: os.exit, os.remove, os.rename, os.setlocale, os.tmpname - // and the shell-related functions - let os_table = lua.create_table()?; - os_table.set( - "clock", - lua.create_function(|_, ()| Ok(std::time::Instant::now().elapsed().as_secs_f64()))?, - )?; - os_table.set("date", lua.create_function(os_date)?)?; - os_table.set( - "difftime", - lua.create_function(|_, (t2, t1): (f64, f64)| Ok(t2 - t1))?, - )?; - os_table.set("time", lua.create_function(os_time)?)?; - globals.set("os", os_table)?; - - // Remove print (plugins should use owlry.log instead) - // We'll add it back via owlry.log - globals.set("print", mlua::Value::Nil)?; - - Ok(()) -} - -/// Safe os.date implementation -fn os_date(_lua: &Lua, format: Option) -> LuaResult { - use chrono::Local; - let now = Local::now(); - let fmt = format.unwrap_or_else(|| "%c".to_string()); - Ok(now.format(&fmt).to_string()) -} - -/// Safe os.time implementation -fn os_time(_lua: &Lua, _args: ()) -> LuaResult { - use std::time::{SystemTime, UNIX_EPOCH}; - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - Ok(duration.as_secs() as i64) -} - -/// Load and run a Lua file in the given runtime -pub fn load_file(lua: &Lua, path: &std::path::Path) -> LuaResult<()> { - let content = std::fs::read_to_string(path).map_err(mlua::Error::external)?; - lua.load(&content) - .set_name(path.file_name().and_then(|n| n.to_str()).unwrap_or("chunk")) - .into_function()? - .call(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_create_sandboxed_runtime() { - let config = SandboxConfig::default(); - let lua = create_lua_runtime(&config).unwrap(); - - // Verify dangerous functions are removed - let result: LuaResult = lua.globals().get("dofile"); - assert!(matches!(result, Ok(mlua::Value::Nil))); - - // Verify safe functions work - let result: String = lua.load("return os.date('%Y')").call(()).unwrap(); - assert!(!result.is_empty()); - } - - #[test] - fn test_basic_lua_operations() { - let config = SandboxConfig::default(); - let lua = create_lua_runtime(&config).unwrap(); - - // Test basic math - let result: i32 = lua.load("return 2 + 2").call(()).unwrap(); - assert_eq!(result, 4); - - // Test table operations - let result: i32 = lua.load("local t = {1,2,3}; return #t").call(()).unwrap(); - assert_eq!(result, 3); - - // Test string operations - let result: String = lua.load("return string.upper('hello')").call(()).unwrap(); - assert_eq!(result, "HELLO"); - } -} diff --git a/crates/owlry-core/src/plugins/runtime_loader.rs b/crates/owlry-core/src/plugins/runtime_loader.rs deleted file mode 100644 index 50045b3..0000000 --- a/crates/owlry-core/src/plugins/runtime_loader.rs +++ /dev/null @@ -1,337 +0,0 @@ -//! Dynamic runtime loader -//! -//! This module provides dynamic loading of script runtimes (Lua, Rune) -//! when they're not compiled into the core binary. -//! -//! Runtimes are loaded from `/usr/lib/owlry/runtimes/`: -//! - `liblua.so` - Lua runtime (from owlry-lua package) -//! - `librune.so` - Rune runtime (from owlry-rune package) -//! -//! Note: This module is infrastructure for the runtime architecture. Full integration -//! is pending Phase 5 (AUR Packaging) when runtime packages will be available. - -use std::mem::ManuallyDrop; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; - -use libloading::{Library, Symbol}; -use owlry_plugin_api::{PluginItem, RStr, RString, RVec}; - -use super::error::{PluginError, PluginResult}; -use crate::providers::{ItemSource, LaunchItem, Provider, ProviderType}; - -/// System directory for runtime libraries -pub const SYSTEM_RUNTIMES_DIR: &str = "/usr/lib/owlry/runtimes"; - -/// Information about a loaded runtime -#[repr(C)] -#[derive(Debug)] -pub struct RuntimeInfo { - pub name: RString, - pub version: RString, -} - -/// Information about a provider from a script runtime -#[repr(C)] -#[derive(Debug, Clone)] -pub struct ScriptProviderInfo { - pub name: RString, - pub display_name: RString, - pub type_id: RString, - pub default_icon: RString, - pub is_static: bool, - pub prefix: owlry_plugin_api::ROption, - pub tab_label: owlry_plugin_api::ROption, - pub search_noun: owlry_plugin_api::ROption, -} - -// Type alias for backwards compatibility -pub type LuaProviderInfo = ScriptProviderInfo; - -/// Handle to runtime-managed state -#[repr(transparent)] -#[derive(Clone, Copy)] -pub struct RuntimeHandle(pub *mut ()); - -// SAFETY: The underlying runtime state (Lua VM, Rune VM) is Send — mlua enables -// the "send" feature and Rune wraps its state in Mutex internally. Access is always -// serialized through Arc>, so there are no data races. -unsafe impl Send for RuntimeHandle {} - -/// VTable for script runtime functions (used by both Lua and Rune) -#[repr(C)] -pub struct ScriptRuntimeVTable { - pub info: extern "C" fn() -> RuntimeInfo, - pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle, - pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec, - pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec, - pub query: extern "C" fn( - handle: RuntimeHandle, - provider_id: RStr<'_>, - query: RStr<'_>, - ) -> RVec, - pub drop: extern "C" fn(handle: RuntimeHandle), -} - -/// A loaded script runtime -pub struct LoadedRuntime { - /// Runtime name (for logging) - name: &'static str, - /// Keep library alive — wrapped in ManuallyDrop so we never call dlclose(). - /// dlclose() unmaps the library code; any thread-local destructors inside the - /// library then SIGSEGV when they try to run against the unmapped addresses. - /// Runtime libraries live for the process lifetime, so leaking the handle is safe. - _library: ManuallyDrop>, - /// Runtime vtable - vtable: &'static ScriptRuntimeVTable, - /// Runtime handle shared with all RuntimeProvider instances for this runtime. - /// Mutex serializes concurrent vtable calls. Arc shares ownership so all - /// RuntimeProviders can call into the runtime through the same handle. - handle: Arc>, - /// Provider information - providers: Vec, -} - -impl LoadedRuntime { - /// Load the Lua runtime from the system directory - pub fn load_lua(plugins_dir: &Path, owlry_version: &str) -> PluginResult { - Self::load_from_path( - "Lua", - &PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so"), - b"owlry_lua_runtime_vtable", - plugins_dir, - owlry_version, - ) - } - - /// Load a runtime from a specific path - fn load_from_path( - name: &'static str, - library_path: &Path, - vtable_symbol: &[u8], - plugins_dir: &Path, - owlry_version: &str, - ) -> PluginResult { - if !library_path.exists() { - return Err(PluginError::NotFound(library_path.display().to_string())); - } - - // SAFETY: We trust the runtime library to be correct - let library = unsafe { Library::new(library_path) } - .map_err(|e| PluginError::LoadError(format!("{}: {}", library_path.display(), e)))?; - - let library = Arc::new(library); - - // Get the vtable - let vtable: &'static ScriptRuntimeVTable = unsafe { - let get_vtable: Symbol &'static ScriptRuntimeVTable> = - library.get(vtable_symbol).map_err(|e| { - PluginError::LoadError(format!( - "{}: Missing vtable symbol: {}", - library_path.display(), - e - )) - })?; - get_vtable() - }; - - // Initialize the runtime - let plugins_dir_str = plugins_dir.to_string_lossy(); - let raw_handle = (vtable.init)(RStr::from_str(&plugins_dir_str), RStr::from_str(owlry_version)); - let handle = Arc::new(Mutex::new(raw_handle)); - - // Get provider information — lock to serialize the vtable call - let providers_rvec = { - let h = handle.lock().unwrap(); - (vtable.providers)(*h) - }; - let providers: Vec = providers_rvec.into_iter().collect(); - - log::info!( - "Loaded {} runtime with {} provider(s)", - name, - providers.len() - ); - - Ok(Self { - name, - _library: ManuallyDrop::new(library), - vtable, - handle, - providers, - }) - } - - /// Get all providers from this runtime - pub fn providers(&self) -> &[ScriptProviderInfo] { - &self.providers - } - - /// Create Provider trait objects for all providers in this runtime - pub fn create_providers(&self) -> Vec> { - self.providers - .iter() - .map(|info| { - let provider = RuntimeProvider::new( - self.name, - self.vtable, - Arc::clone(&self.handle), - info.clone(), - ); - Box::new(provider) as Box - }) - .collect() - } -} - -impl Drop for LoadedRuntime { - fn drop(&mut self) { - let h = self.handle.lock().unwrap(); - (self.vtable.drop)(*h); - // Do NOT drop _library: ManuallyDrop ensures dlclose() is never called. - // See field comment for rationale. - } -} -// LoadedRuntime is Send + Sync because: -// - Arc> is Send + Sync (RuntimeHandle: Send via unsafe impl above) -// - All other fields are 'static references or Send types -// No unsafe impl needed — this is derived automatically. - -/// A provider backed by a dynamically loaded runtime -pub struct RuntimeProvider { - /// Runtime name (for logging) - #[allow(dead_code)] - runtime_name: &'static str, - vtable: &'static ScriptRuntimeVTable, - /// Shared with the owning LoadedRuntime and sibling RuntimeProviders. - /// Mutex serializes concurrent vtable calls on the same runtime handle. - handle: Arc>, - info: ScriptProviderInfo, - items: Vec, -} - -impl RuntimeProvider { - fn new( - runtime_name: &'static str, - vtable: &'static ScriptRuntimeVTable, - handle: Arc>, - info: ScriptProviderInfo, - ) -> Self { - Self { - runtime_name, - vtable, - handle, - info, - items: Vec::new(), - } - } - - fn convert_item(&self, item: PluginItem) -> LaunchItem { - LaunchItem { - id: item.id.to_string(), - name: item.name.to_string(), - description: item.description.into_option().map(|s| s.to_string()), - icon: item.icon.into_option().map(|s| s.to_string()), - provider: ProviderType::Plugin(self.info.type_id.to_string()), - command: item.command.to_string(), - terminal: item.terminal, - tags: item.keywords.iter().map(|s| s.to_string()).collect(), - source: ItemSource::ScriptPlugin, - } - } -} - -impl Provider for RuntimeProvider { - fn name(&self) -> &str { - self.info.name.as_str() - } - - fn provider_type(&self) -> ProviderType { - ProviderType::Plugin(self.info.type_id.to_string()) - } - - fn refresh(&mut self) { - if !self.info.is_static { - return; - } - - let name_rstr = RStr::from_str(self.info.name.as_str()); - let items_rvec = { - let h = self.handle.lock().unwrap(); - (self.vtable.refresh)(*h, name_rstr) - }; - self.items = items_rvec - .into_iter() - .map(|i| self.convert_item(i)) - .collect(); - - log::debug!( - "[RuntimeProvider] '{}' refreshed with {} items", - self.info.name, - self.items.len() - ); - } - - fn items(&self) -> &[LaunchItem] { - &self.items - } - - fn tab_label(&self) -> Option<&str> { - self.info.tab_label.as_ref().map(|s| s.as_str()).into() - } - - fn search_noun(&self) -> Option<&str> { - self.info.search_noun.as_ref().map(|s| s.as_str()).into() - } -} - -// RuntimeProvider is Send + Sync because: -// - Arc> is Send + Sync (RuntimeHandle: Send via unsafe impl above) -// - vtable is &'static (Send + Sync), info and items are Send -// No unsafe impl needed — this is derived automatically. - -/// Check if the Lua runtime is available -pub fn lua_runtime_available() -> bool { - PathBuf::from(SYSTEM_RUNTIMES_DIR) - .join("liblua.so") - .exists() -} - -/// Check if the Rune runtime is available -pub fn rune_runtime_available() -> bool { - PathBuf::from(SYSTEM_RUNTIMES_DIR) - .join("librune.so") - .exists() -} - -impl LoadedRuntime { - /// Load the Rune runtime from the system directory - pub fn load_rune(plugins_dir: &Path, owlry_version: &str) -> PluginResult { - Self::load_from_path( - "Rune", - &PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so"), - b"owlry_rune_runtime_vtable", - plugins_dir, - owlry_version, - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_lua_runtime_check_doesnt_panic() { - // Just verify the function runs without panicking - // Result depends on whether runtime is installed - let _available = lua_runtime_available(); - } - - #[test] - fn test_rune_runtime_check_doesnt_panic() { - // Just verify the function runs without panicking - // Result depends on whether runtime is installed - let _available = rune_runtime_available(); - } -} diff --git a/crates/owlry-core/src/plugins/watcher.rs b/crates/owlry-core/src/plugins/watcher.rs deleted file mode 100644 index af828c0..0000000 --- a/crates/owlry-core/src/plugins/watcher.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! Filesystem watcher for user plugin hot-reload -//! -//! Watches `~/.config/owlry/plugins/` for changes and triggers -//! runtime reload when plugin files are modified. - -use std::path::PathBuf; -use std::sync::{Arc, RwLock}; -use std::thread; -use std::time::Duration; - -use log::{info, warn}; -use notify_debouncer_mini::{new_debouncer, DebouncedEventKind}; - -use crate::providers::ProviderManager; - -/// Start watching the user plugins directory for changes. -/// -/// Spawns a background thread that monitors the directory and triggers -/// a full runtime reload on any file change. Returns immediately. -/// -/// Respects `OWLRY_SKIP_RUNTIMES=1` — returns early if set. -pub fn start_watching(pm: Arc>) { - if std::env::var("OWLRY_SKIP_RUNTIMES").is_ok() { - info!("OWLRY_SKIP_RUNTIMES set, skipping file watcher"); - return; - } - - let plugins_dir = match crate::paths::plugins_dir() { - Some(d) => d, - None => { - info!("No plugins directory configured, skipping file watcher"); - return; - } - }; - - if !plugins_dir.exists() - && std::fs::create_dir_all(&plugins_dir).is_err() - { - warn!( - "Failed to create plugins directory: {}", - plugins_dir.display() - ); - return; - } - - info!( - "Plugin file watcher started for {}", - plugins_dir.display() - ); - - thread::spawn(move || { - if let Err(e) = watch_loop(&plugins_dir, &pm) { - warn!("Plugin watcher stopped: {}", e); - } - }); -} - -fn watch_loop( - plugins_dir: &PathBuf, - pm: &Arc>, -) -> Result<(), Box> { - let (tx, rx) = std::sync::mpsc::channel(); - - let mut debouncer = new_debouncer(Duration::from_millis(500), tx)?; - - debouncer - .watcher() - .watch(plugins_dir.as_ref(), notify::RecursiveMode::Recursive)?; - - info!("Watching {} for plugin changes", plugins_dir.display()); - - // Skip events during initial startup grace period (watcher setup triggers events) - let startup = std::time::Instant::now(); - let grace_period = Duration::from_secs(2); - - loop { - match rx.recv() { - Ok(Ok(events)) => { - if startup.elapsed() < grace_period { - continue; - } - - let has_relevant_change = events.iter().any(|e| { - matches!( - e.kind, - DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous - ) - }); - - if has_relevant_change { - info!("Plugin file change detected, reloading runtimes..."); - match pm.write() { - Ok(mut pm_guard) => pm_guard.reload_runtimes(), - Err(_) => { - log::error!("Plugin watcher: provider lock poisoned; stopping watcher"); - return Err(Box::from("provider lock poisoned")); - } - } - } - } - Ok(Err(error)) => { - warn!("File watcher error: {}", error); - } - Err(e) => { - return Err(Box::new(e)); - } - } - } -} diff --git a/crates/owlry-core/src/providers/config_editor.rs b/crates/owlry-core/src/providers/config_editor.rs deleted file mode 100644 index e15cb56..0000000 --- a/crates/owlry-core/src/providers/config_editor.rs +++ /dev/null @@ -1,1127 +0,0 @@ -use std::sync::{Arc, RwLock}; - -use log::warn; - -use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType}; -use crate::config::Config; - -const ICON: &str = "preferences-system-symbolic"; -const PROVIDER_TYPE_ID: &str = "config"; - -/// Known search engines for the engine selection list. -const SEARCH_ENGINES: &[&str] = &[ - "duckduckgo", - "google", - "bing", - "startpage", - "searxng", - "brave", - "ecosia", -]; - -/// Known built-in theme names (always available). -const BUILTIN_THEMES: &[&str] = &["owl"]; - -/// Boolean provider fields that can be toggled via CONFIG:toggle:providers.*. -/// Only built-in providers are listed here; plugins are enabled/disabled via -/// [plugins] disabled_plugins in config.toml or `owlry plugin enable/disable`. -const PROVIDER_TOGGLES: &[(&str, &str)] = &[ - ("applications", "Applications"), - ("commands", "Commands"), - ("calculator", "Calculator"), - ("converter", "Unit Converter"), - ("system", "System Actions"), - ("frecency", "Frecency Ranking"), -]; - -/// Built-in config editor provider. Interprets query text as a navigation path -/// and generates settings items the user can activate to change configuration. -pub(crate) struct ConfigProvider { - config: Arc>, -} - -impl ConfigProvider { - pub fn new(config: Arc>) -> Self { - Self { config } - } - - /// Execute a `CONFIG:*` action command. Returns `true` if handled. - /// - /// Acquires the write lock once and holds it across both the mutation and - /// the subsequent save, eliminating the TOCTOU window that would exist if - /// the sub-handlers each acquired the lock independently. - fn handle_config_action(&self, command: &str) -> bool { - let Some(rest) = command.strip_prefix("CONFIG:") else { - return false; - }; - - let mut cfg = match self.config.write() { - Ok(c) => c, - Err(_) => return false, - }; - - let result = if let Some(path) = rest.strip_prefix("toggle:") { - Self::toggle_config(&mut cfg, path) - } else if let Some(kv) = rest.strip_prefix("set:") { - Self::set_config(&mut cfg, kv) - } else if let Some(profile_cmd) = rest.strip_prefix("profile:") { - Self::profile_config(&mut cfg, profile_cmd) - } else { - false - }; - - #[cfg(not(test))] - if result - && let Err(e) = cfg.save() - { - warn!("Failed to save config: {}", e); - } - - result - } - - // ── Toggle handler ────────────────────────────────────────────────── - - fn toggle_config(cfg: &mut Config, path: &str) -> bool { - match path { - "providers.applications" => { - cfg.providers.applications = !cfg.providers.applications; - true - } - "providers.commands" => { - cfg.providers.commands = !cfg.providers.commands; - true - } - "providers.calculator" => { - cfg.providers.calculator = !cfg.providers.calculator; - true - } - "providers.converter" => { - cfg.providers.converter = !cfg.providers.converter; - true - } - "providers.system" => { - cfg.providers.system = !cfg.providers.system; - true - } - "providers.frecency" => { - cfg.providers.frecency = !cfg.providers.frecency; - true - } - "general.show_icons" => { - cfg.general.show_icons = !cfg.general.show_icons; - true - } - "general.use_uwsm" => { - cfg.general.use_uwsm = !cfg.general.use_uwsm; - true - } - _ => false, - } - } - - // ── Set handler ───────────────────────────────────────────────────── - - fn set_config(cfg: &mut Config, kv: &str) -> bool { - let Some((path, value)) = kv.split_once(':') else { - return false; - }; - - match path { - "appearance.theme" => { - cfg.appearance.theme = if value == "none" { - None - } else { - Some(value.to_string()) - }; - true - } - "appearance.font_size" => { - if let Ok(v) = value.parse::() { - cfg.appearance.font_size = v; - true - } else { - false - } - } - "appearance.width" => { - if let Ok(v) = value.parse::() { - cfg.appearance.width = v; - true - } else { - false - } - } - "appearance.height" => { - if let Ok(v) = value.parse::() { - cfg.appearance.height = v; - true - } else { - false - } - } - "appearance.border_radius" => { - if let Ok(v) = value.parse::() { - cfg.appearance.border_radius = v; - true - } else { - false - } - } - "providers.search_engine" => { - cfg.providers.search_engine = value.to_string(); - true - } - "providers.frecency_weight" => { - if let Ok(v) = value.parse::() { - cfg.providers.frecency_weight = v.clamp(0.0, 1.0); - true - } else { - false - } - } - _ => false, - } - } - - // ── Profile handler ───────────────────────────────────────────────── - - fn profile_config(cfg: &mut Config, cmd: &str) -> bool { - if let Some(name) = cmd.strip_prefix("create:") { - if !name.is_empty() && !cfg.profiles.contains_key(name) { - cfg.profiles.insert( - name.to_string(), - crate::config::ProfileConfig::default(), - ); - true - } else { - false - } - } else if let Some(name) = cmd.strip_prefix("delete:") { - cfg.profiles.remove(name).is_some() - } else if let Some(rest) = cmd.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_name = parts[2]; - if let Some(profile) = cfg.profiles.get_mut(profile_name) { - if let Some(pos) = profile.modes.iter().position(|m| m == mode_name) { - profile.modes.remove(pos); - } else { - profile.modes.push(mode_name.to_string()); - } - true - } else { - false - } - } else { - false - } - } else { - false - } - } - - // ── Query routing ─────────────────────────────────────────────────── - - fn query_top_level(&self) -> Vec { - vec![ - nav_item("config:cat:providers", "Providers", "Toggle search providers on/off"), - nav_item("config:cat:theme", "Theme", "Select UI theme"), - nav_item("config:cat:engine", "Engine", "Search engine for web search"), - nav_item("config:cat:frecency", "Frecency", "Frecency ranking settings"), - nav_item("config:cat:fontsize", "Font Size", "Adjust font size"), - nav_item("config:cat:width", "Width", "Adjust window width"), - nav_item("config:cat:height", "Height", "Adjust window height"), - nav_item("config:cat:radius", "Border Radius", "Adjust border radius"), - nav_item("config:cat:profiles", "Profiles", "Manage provider profiles"), - ] - } - - fn query_providers(&self, filter: &str) -> Vec { - let cfg = match self.config.read() { - Ok(c) => c, - Err(_) => return Vec::new(), - }; - - PROVIDER_TOGGLES - .iter() - .filter(|(_, label)| { - filter.is_empty() || label.to_lowercase().contains(&filter.to_lowercase()) - }) - .map(|(field, label)| { - let enabled = get_provider_bool(&cfg, field); - let marker = if enabled { "\u{2713}" } else { "\u{2717}" }; - let status = if enabled { "enabled" } else { "disabled" }; - LaunchItem { - id: format!("config:toggle:providers.{}", field), - name: format!("{} {}", marker, label), - description: Some(format!("Currently {}", status)), - icon: Some(ICON.into()), - provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()), - command: format!("CONFIG:toggle:providers.{}", field), - terminal: false, - tags: vec!["config".into(), "settings".into()], - source: ItemSource::Core, - } - }) - .collect() - } - - fn query_theme(&self, filter: &str) -> Vec { - let cfg = match self.config.read() { - Ok(c) => c, - Err(_) => return Vec::new(), - }; - - let current = cfg.appearance.theme.as_deref().unwrap_or("(GTK default)"); - - let mut themes: Vec<&str> = Vec::new(); - themes.push("none"); // GTK default - for t in BUILTIN_THEMES { - themes.push(t); - } - - // Discover user themes from filesystem - let user_themes = discover_user_themes(); - let user_theme_refs: Vec<&str> = user_themes.iter().map(|s| s.as_str()).collect(); - themes.extend_from_slice(&user_theme_refs); - - themes - .into_iter() - .filter(|t| { - if filter.is_empty() { - true - } else { - t.to_lowercase().contains(&filter.to_lowercase()) - } - }) - .map(|theme_name| { - let display = if theme_name == "none" { - "GTK Default".to_string() - } else { - theme_name.to_string() - }; - let is_current = if theme_name == "none" { - cfg.appearance.theme.is_none() - } else { - cfg.appearance.theme.as_deref() == Some(theme_name) - }; - let marker = if is_current { "\u{25cf} " } else { " " }; - LaunchItem { - id: format!("config:theme:{}", theme_name), - name: format!("{}{}", marker, display), - description: Some(format!("Current: {}", current)), - icon: Some(ICON.into()), - provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()), - command: format!("CONFIG:set:appearance.theme:{}", theme_name), - terminal: false, - tags: vec!["config".into(), "settings".into()], - source: ItemSource::Core, - } - }) - .collect() - } - - fn query_engine(&self, filter: &str) -> Vec { - let cfg = match self.config.read() { - Ok(c) => c, - Err(_) => return Vec::new(), - }; - - let current = &cfg.providers.search_engine; - - SEARCH_ENGINES - .iter() - .filter(|e| { - filter.is_empty() || e.to_lowercase().contains(&filter.to_lowercase()) - }) - .map(|engine| { - let is_current = *engine == current.as_str(); - let marker = if is_current { "\u{25cf} " } else { " " }; - LaunchItem { - id: format!("config:engine:{}", engine), - name: format!("{}{}", marker, engine), - description: Some(format!("Current: {}", current)), - icon: Some(ICON.into()), - provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()), - command: format!("CONFIG:set:providers.search_engine:{}", engine), - terminal: false, - tags: vec!["config".into(), "settings".into()], - source: ItemSource::Core, - } - }) - .collect() - } - - fn query_frecency(&self, input: &str) -> Vec { - let cfg = match self.config.read() { - Ok(c) => c, - Err(_) => return Vec::new(), - }; - - let mut items = Vec::new(); - - // Toggle item - let enabled = cfg.providers.frecency; - let marker = if enabled { "\u{2713}" } else { "\u{2717}" }; - let status = if enabled { "enabled" } else { "disabled" }; - items.push(LaunchItem { - id: "config:toggle:providers.frecency".into(), - name: format!("{} Frecency Ranking", marker), - description: Some(format!("Currently {} (weight: {})", status, cfg.providers.frecency_weight)), - icon: Some(ICON.into()), - provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()), - command: "CONFIG:toggle:providers.frecency".into(), - terminal: false, - tags: vec!["config".into(), "settings".into()], - source: ItemSource::Core, - }); - - // If numeric input, offer a set-weight action - if let Ok(weight) = input.parse::() { - let clamped = weight.clamp(0.0, 1.0); - items.push(LaunchItem { - id: format!("config:set:frecency_weight:{}", clamped), - name: format!("Set weight to {}", clamped), - description: Some(format!("Current: {}", cfg.providers.frecency_weight)), - icon: Some(ICON.into()), - provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()), - command: format!("CONFIG:set:providers.frecency_weight:{}", clamped), - terminal: false, - tags: vec!["config".into(), "settings".into()], - source: ItemSource::Core, - }); - } - - items - } - - fn query_numeric( - &self, - category: &str, - config_path: &str, - label: &str, - current_value: &str, - input: &str, - ) -> Vec { - let mut items = Vec::new(); - - // Show current value - items.push(LaunchItem { - id: format!("config:current:{}", category), - name: format!("{}: {}", label, current_value), - description: Some("Type a number to change".into()), - icon: Some(ICON.into()), - provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()), - command: String::new(), - terminal: false, - tags: vec!["config".into(), "settings".into()], - source: ItemSource::Core, - }); - - // If numeric input, offer a set action - if !input.is_empty() { - // Validate it parses as the expected type - let valid = input.parse::().is_ok(); - if valid { - items.push(LaunchItem { - id: format!("config:set:{}:{}", category, input), - name: format!("Set {} to {}", label, input), - description: Some(format!("Current: {}", current_value)), - icon: Some(ICON.into()), - provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()), - command: format!("CONFIG:set:{}:{}", config_path, input), - terminal: false, - tags: vec!["config".into(), "settings".into()], - source: ItemSource::Core, - }); - } - } - - items - } - - fn query_profiles(&self, filter: &str) -> Vec { - let cfg = match self.config.read() { - Ok(c) => c, - Err(_) => return Vec::new(), - }; - - let mut items = Vec::new(); - - for (name, profile) in &cfg.profiles { - if !filter.is_empty() && !name.to_lowercase().contains(&filter.to_lowercase()) { - continue; - } - let modes = profile.modes.join(", "); - let desc = if modes.is_empty() { - "No modes configured".to_string() - } else { - format!("Modes: {}", modes) - }; - items.push(nav_item( - &format!("config:profile:{}", name), - name, - &desc, - )); - } - - // Hint for creating - if filter.is_empty() { - items.push(LaunchItem { - id: "config:profile:create:hint".into(), - name: "Type a name to create a new profile".into(), - description: None, - icon: Some(ICON.into()), - provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()), - command: String::new(), - terminal: false, - tags: vec!["config".into(), "settings".into()], - source: ItemSource::Core, - }); - } - - items - } - - fn query_profile_create(&self, name: &str) -> Vec { - let cfg = match self.config.read() { - Ok(c) => c, - Err(_) => return Vec::new(), - }; - - if cfg.profiles.contains_key(name) { - return Vec::new(); - } - - vec![LaunchItem { - id: format!("config:profile:create:{}", name), - name: format!("Create profile '{}'", name), - description: Some("Creates an empty profile".into()), - icon: Some(ICON.into()), - provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()), - command: format!("CONFIG:profile:create:{}", name), - terminal: false, - tags: vec!["config".into(), "settings".into()], - source: ItemSource::Core, - }] - } - - fn query_profile_detail(&self, profile_name: &str) -> Vec { - let cfg = match self.config.read() { - Ok(c) => c, - Err(_) => return Vec::new(), - }; - - if !cfg.profiles.contains_key(profile_name) { - return Vec::new(); - } - - vec![ - nav_item( - &format!("config:profile:{}:modes", profile_name), - "Edit Modes", - "Toggle which modes are included", - ), - LaunchItem { - id: format!("config:profile:delete:{}", profile_name), - name: format!("Delete profile '{}'", profile_name), - description: None, - icon: Some(ICON.into()), - provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()), - command: format!("CONFIG:profile:delete:{}", profile_name), - terminal: false, - tags: vec!["config".into(), "settings".into()], - source: ItemSource::Core, - }, - ] - } - - fn query_profile_modes(&self, profile_name: &str) -> Vec { - let cfg = match self.config.read() { - Ok(c) => c, - Err(_) => return Vec::new(), - }; - - let Some(profile) = cfg.profiles.get(profile_name) else { - return Vec::new(); - }; - - let all_modes = [ - "app", "cmd", "dmenu", "calc", "clip", "emoji", "ssh", "sys", "bm", "file", "web", - "uuctl", - ]; - - all_modes - .iter() - .map(|mode| { - let active = profile.modes.iter().any(|m| m == mode); - let marker = if active { "\u{2713}" } else { "\u{2717}" }; - LaunchItem { - id: format!("config:profile:{}:mode:{}", profile_name, mode), - name: format!("{} {}", marker, mode), - description: None, - icon: Some(ICON.into()), - provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()), - command: format!( - "CONFIG:profile:mode:{}:toggle:{}", - profile_name, mode - ), - terminal: false, - tags: vec!["config".into(), "settings".into()], - source: ItemSource::Core, - } - }) - .collect() - } -} - -impl DynamicProvider for ConfigProvider { - fn name(&self) -> &str { - "Config Editor" - } - - fn provider_type(&self) -> ProviderType { - ProviderType::Plugin(PROVIDER_TYPE_ID.into()) - } - - fn priority(&self) -> u32 { - 8000 - } - - fn execute_action(&self, command: &str) -> bool { - self.handle_config_action(command) - } - - fn query(&self, query: &str) -> Vec { - let q = query.trim().to_lowercase(); - - if q.is_empty() { - return self.query_top_level(); - } - - // Route based on first path segment - let (segment, rest) = match q.find(' ') { - Some(pos) => (&q[..pos], q[pos + 1..].trim()), - None => (q.as_str(), ""), - }; - - match segment { - "providers" => self.query_providers(rest), - "theme" => self.query_theme(rest), - "engine" => self.query_engine(rest), - "frecency" => self.query_frecency(rest), - "fontsize" => { - let cfg = self.config.read().ok(); - let current = cfg - .as_ref() - .map(|c| c.appearance.font_size.to_string()) - .unwrap_or_default(); - self.query_numeric("fontsize", "appearance.font_size", "Font Size", ¤t, rest) - } - "width" => { - let cfg = self.config.read().ok(); - let current = cfg - .as_ref() - .map(|c| c.appearance.width.to_string()) - .unwrap_or_default(); - self.query_numeric("width", "appearance.width", "Width", ¤t, rest) - } - "height" => { - let cfg = self.config.read().ok(); - let current = cfg - .as_ref() - .map(|c| c.appearance.height.to_string()) - .unwrap_or_default(); - self.query_numeric("height", "appearance.height", "Height", ¤t, rest) - } - "radius" => { - let cfg = self.config.read().ok(); - let current = cfg - .as_ref() - .map(|c| c.appearance.border_radius.to_string()) - .unwrap_or_default(); - self.query_numeric( - "radius", - "appearance.border_radius", - "Border Radius", - ¤t, - rest, - ) - } - "profiles" => self.query_profiles(rest), - "profile" => { - if rest.is_empty() { - self.query_profiles("") - } else { - let (profile_name, sub) = match rest.find(' ') { - Some(pos) => (&rest[..pos], rest[pos + 1..].trim()), - None => (rest, ""), - }; - if profile_name == "create" { - if sub.is_empty() { - Vec::new() - } else { - self.query_profile_create(sub) - } - } else if sub.is_empty() { - self.query_profile_detail(profile_name) - } else if sub == "modes" { - self.query_profile_modes(profile_name) - } else { - Vec::new() - } - } - } - _ => { - // Fuzzy filter top-level categories - self.query_top_level() - .into_iter() - .filter(|item| item.name.to_lowercase().contains(&q)) - .collect() - } - } - } -} - -// ── Helpers ───────────────────────────────────────────────────────────── - -/// Create a navigation item (no action command — selecting it refines the query). -fn nav_item(id: &str, name: &str, description: &str) -> LaunchItem { - LaunchItem { - id: id.to_string(), - name: name.to_string(), - description: Some(description.to_string()), - icon: Some(ICON.into()), - provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()), - command: String::new(), - terminal: false, - tags: vec!["config".into(), "settings".into()], - source: ItemSource::Core, - } -} - -/// Read a boolean field from ProvidersConfig by field name. -fn get_provider_bool(cfg: &Config, field: &str) -> bool { - match field { - "applications" => cfg.providers.applications, - "commands" => cfg.providers.commands, - "calculator" => cfg.providers.calculator, - "converter" => cfg.providers.converter, - "system" => cfg.providers.system, - "frecency" => cfg.providers.frecency, - _ => false, - } -} - -/// Discover user theme CSS files from the themes directory. -fn discover_user_themes() -> Vec { - let Some(dir) = crate::paths::themes_dir() else { - return Vec::new(); - }; - let Ok(entries) = std::fs::read_dir(dir) else { - return Vec::new(); - }; - - let mut themes: Vec = entries - .filter_map(|e| e.ok()) - .filter_map(|e| { - let path = e.path(); - if path.extension().and_then(|ext| ext.to_str()) == Some("css") { - path.file_stem() - .and_then(|s| s.to_str()) - .map(|s| s.to_string()) - } else { - None - } - }) - // Exclude the built-in theme name to avoid duplicates - .filter(|name| !BUILTIN_THEMES.contains(&name.as_str())) - .collect(); - - themes.sort(); - themes -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::{Arc, RwLock}; - - fn make_provider() -> ConfigProvider { - ConfigProvider::new(Arc::new(RwLock::new(Config::default()))) - } - - fn make_provider_with_config(config: Config) -> ConfigProvider { - ConfigProvider::new(Arc::new(RwLock::new(config))) - } - - // ── Top-level ─────────────────────────────────────────────────────── - - #[test] - fn empty_query_returns_top_level_categories() { - let p = make_provider(); - let results = p.query(""); - assert!(results.len() >= 7, "expected at least 7 categories, got {}", results.len()); - let names: Vec<&str> = results.iter().map(|r| r.name.as_str()).collect(); - assert!(names.contains(&"Providers")); - assert!(names.contains(&"Theme")); - assert!(names.contains(&"Engine")); - assert!(names.contains(&"Frecency")); - assert!(names.contains(&"Font Size")); - assert!(names.contains(&"Profiles")); - } - - // ── Provider toggles ──────────────────────────────────────────────── - - #[test] - fn providers_query_shows_toggle_list() { - let p = make_provider(); - let results = p.query("providers"); - assert!(!results.is_empty()); - // Default config has all providers enabled - assert!(results[0].name.contains('\u{2713}')); - assert!(results[0].command.starts_with("CONFIG:toggle:providers.")); - } - - #[test] - fn toggle_action_flips_boolean_and_back() { - let p = make_provider(); - - // Calculator starts enabled - assert!(p.config.read().unwrap().providers.calculator); - - // Toggle off - assert!(p.execute_action("CONFIG:toggle:providers.calculator")); - assert!(!p.config.read().unwrap().providers.calculator); - - // Toggle back on - assert!(p.execute_action("CONFIG:toggle:providers.calculator")); - assert!(p.config.read().unwrap().providers.calculator); - } - - // ── Theme ─────────────────────────────────────────────────────────── - - #[test] - fn theme_items_mark_current_with_bullet() { - let p = make_provider(); - let results = p.query("theme"); - // Default config has no theme set -> GTK Default is current - let gtk_item = results.iter().find(|r| r.name.contains("GTK Default")).unwrap(); - assert!(gtk_item.name.starts_with("\u{25cf}"), "expected bullet marker on current theme"); - } - - #[test] - fn set_theme_action_updates_config() { - let p = make_provider(); - - assert!(p.execute_action("CONFIG:set:appearance.theme:owl")); - assert_eq!( - p.config.read().unwrap().appearance.theme.as_deref(), - Some("owl") - ); - } - - #[test] - fn set_theme_to_none_clears_theme() { - let mut cfg = Config::default(); - cfg.appearance.theme = Some("owl".into()); - let p = make_provider_with_config(cfg); - - assert!(p.execute_action("CONFIG:set:appearance.theme:none")); - assert!(p.config.read().unwrap().appearance.theme.is_none()); - } - - // ── Numeric values ────────────────────────────────────────────────── - - #[test] - fn set_numeric_font_size() { - let p = make_provider(); - - assert!(p.execute_action("CONFIG:set:appearance.font_size:16")); - assert_eq!(p.config.read().unwrap().appearance.font_size, 16); - } - - #[test] - fn fontsize_query_with_number_generates_set_action() { - let p = make_provider(); - let results = p.query("fontsize 16"); - assert!(results.len() >= 2, "expected current + set action items"); - let set_item = results.iter().find(|r| r.name.contains("Set")).unwrap(); - assert_eq!(set_item.command, "CONFIG:set:appearance.font_size:16"); - } - - #[test] - fn set_width_works() { - let p = make_provider(); - assert!(p.execute_action("CONFIG:set:appearance.width:900")); - assert_eq!(p.config.read().unwrap().appearance.width, 900); - } - - #[test] - fn set_height_works() { - let p = make_provider(); - assert!(p.execute_action("CONFIG:set:appearance.height:700")); - assert_eq!(p.config.read().unwrap().appearance.height, 700); - } - - #[test] - fn set_border_radius_works() { - let p = make_provider(); - assert!(p.execute_action("CONFIG:set:appearance.border_radius:8")); - assert_eq!(p.config.read().unwrap().appearance.border_radius, 8); - } - - // ── Frecency ──────────────────────────────────────────────────────── - - #[test] - fn frecency_weight_clamped_to_range() { - let p = make_provider(); - - // Above 1.0 clamped to 1.0 - assert!(p.execute_action("CONFIG:set:providers.frecency_weight:2.5")); - assert_eq!(p.config.read().unwrap().providers.frecency_weight, 1.0); - - // Below 0.0 clamped to 0.0 - assert!(p.execute_action("CONFIG:set:providers.frecency_weight:-0.5")); - assert_eq!(p.config.read().unwrap().providers.frecency_weight, 0.0); - - // Normal value accepted - assert!(p.execute_action("CONFIG:set:providers.frecency_weight:0.5")); - assert_eq!(p.config.read().unwrap().providers.frecency_weight, 0.5); - } - - #[test] - fn frecency_query_shows_toggle_and_weight() { - let p = make_provider(); - let results = p.query("frecency"); - assert!(!results.is_empty()); - assert!(results[0].name.contains("Frecency Ranking")); - assert!(results[0].description.as_ref().unwrap().contains("weight")); - } - - #[test] - fn frecency_numeric_input_generates_set_action() { - let p = make_provider(); - let results = p.query("frecency 0.5"); - let set_item = results.iter().find(|r| r.name.contains("Set weight")).unwrap(); - assert_eq!(set_item.command, "CONFIG:set:providers.frecency_weight:0.5"); - } - - // ── Engine ────────────────────────────────────────────────────────── - - #[test] - fn engine_query_lists_engines_with_current_marker() { - let p = make_provider(); - let results = p.query("engine"); - assert!(!results.is_empty()); - // Default is duckduckgo — should have bullet marker - let ddg = results.iter().find(|r| r.name.contains("duckduckgo")).unwrap(); - assert!(ddg.name.contains("\u{25cf}")); - } - - #[test] - fn set_engine_updates_config() { - let p = make_provider(); - assert!(p.execute_action("CONFIG:set:providers.search_engine:google")); - assert_eq!(p.config.read().unwrap().providers.search_engine, "google"); - } - - // ── Invalid actions ───────────────────────────────────────────────── - - #[test] - fn invalid_action_returns_false() { - let p = make_provider(); - assert!(!p.execute_action("CONFIG:toggle:nonexistent.field")); - assert!(!p.execute_action("CONFIG:set:nonexistent.field:value")); - assert!(!p.execute_action("NOTCONFIG:something")); - assert!(!p.execute_action("CONFIG:unknown_verb:something")); - } - - // ── Provider type ─────────────────────────────────────────────────── - - #[test] - fn provider_type_is_plugin_config() { - let p = make_provider(); - assert_eq!( - p.provider_type(), - ProviderType::Plugin("config".into()) - ); - } - - #[test] - fn provider_priority_is_8000() { - let p = make_provider(); - assert_eq!(p.priority(), 8000); - } - - // ── Profiles ──────────────────────────────────────────────────────── - - #[test] - fn profile_create_action_adds_profile() { - let p = make_provider(); - assert!(p.execute_action("CONFIG:profile:create:dev")); - assert!(p.config.read().unwrap().profiles.contains_key("dev")); - } - - #[test] - fn profile_create_duplicate_returns_false() { - let p = make_provider(); - assert!(p.execute_action("CONFIG:profile:create:dev")); - assert!(!p.execute_action("CONFIG:profile:create:dev")); - } - - #[test] - fn profile_delete_action_removes_profile() { - let p = make_provider(); - assert!(p.execute_action("CONFIG:profile:create:dev")); - assert!(p.execute_action("CONFIG:profile:delete:dev")); - assert!(!p.config.read().unwrap().profiles.contains_key("dev")); - } - - #[test] - fn profile_delete_nonexistent_returns_false() { - let p = make_provider(); - assert!(!p.execute_action("CONFIG:profile:delete:nonexistent")); - } - - #[test] - fn profile_mode_toggle_adds_and_removes() { - let p = make_provider(); - assert!(p.execute_action("CONFIG:profile:create:dev")); - - // Add mode - assert!(p.execute_action("CONFIG:profile:mode:dev:toggle:ssh")); - assert!( - p.config - .read() - .unwrap() - .profiles - .get("dev") - .unwrap() - .modes - .contains(&"ssh".to_string()) - ); - - // Remove mode - assert!(p.execute_action("CONFIG:profile:mode:dev:toggle:ssh")); - assert!( - !p.config - .read() - .unwrap() - .profiles - .get("dev") - .unwrap() - .modes - .contains(&"ssh".to_string()) - ); - } - - #[test] - fn profile_mode_toggle_nonexistent_profile_returns_false() { - let p = make_provider(); - assert!(!p.execute_action("CONFIG:profile:mode:nonexistent:toggle:ssh")); - } - - #[test] - fn profile_create_query_generates_correct_action_item() { - let p = make_provider(); - let results = p.query("profile create dev"); - assert_eq!(results.len(), 1); - assert!(results[0].name.contains("Create profile 'dev'")); - assert_eq!(results[0].command, "CONFIG:profile:create:dev"); - } - - #[test] - fn profiles_query_lists_existing_profiles() { - let mut cfg = Config::default(); - cfg.profiles.insert( - "dev".into(), - crate::config::ProfileConfig { - modes: vec!["app".into(), "cmd".into()], - }, - ); - let p = make_provider_with_config(cfg); - - let results = p.query("profiles"); - assert!(results.iter().any(|r| r.name == "dev")); - } - - #[test] - fn profile_detail_shows_edit_and_delete() { - let mut cfg = Config::default(); - cfg.profiles.insert( - "dev".into(), - crate::config::ProfileConfig { - modes: vec!["app".into()], - }, - ); - let p = make_provider_with_config(cfg); - - let results = p.query("profile dev"); - let names: Vec<&str> = results.iter().map(|r| r.name.as_str()).collect(); - assert!(names.contains(&"Edit Modes")); - assert!(names.iter().any(|n| n.contains("Delete"))); - } - - #[test] - fn profile_modes_shows_checklist() { - let mut cfg = Config::default(); - cfg.profiles.insert( - "dev".into(), - crate::config::ProfileConfig { - modes: vec!["app".into(), "ssh".into()], - }, - ); - let p = make_provider_with_config(cfg); - - let results = p.query("profile dev modes"); - assert!(!results.is_empty()); - - // app and ssh should be checked - let app_item = results.iter().find(|r| r.name.contains("app")).unwrap(); - assert!(app_item.name.contains('\u{2713}')); - - let cmd_item = results.iter().find(|r| r.name.contains("cmd")).unwrap(); - assert!(cmd_item.name.contains('\u{2717}')); - } - - // ── Edge cases ────────────────────────────────────────────────────── - - #[test] - fn navigation_items_have_empty_command() { - let p = make_provider(); - let results = p.query(""); - for item in &results { - assert!(item.command.is_empty(), "nav item '{}' should have empty command", item.name); - } - } - - #[test] - fn all_items_have_config_tags() { - let p = make_provider(); - let results = p.query("providers"); - for item in &results { - assert!(item.tags.contains(&"config".into())); - assert!(item.tags.contains(&"settings".into())); - } - } - - #[test] - fn provider_filter_narrows_results() { - let p = make_provider(); - let all = p.query("providers"); - let filtered = p.query("providers calc"); - assert!(filtered.len() < all.len()); - assert!(filtered.iter().all(|r| r.name.to_lowercase().contains("calc"))); - } - - #[test] - fn theme_filter_narrows_results() { - let p = make_provider(); - let results = p.query("theme owl"); - assert!(results.iter().any(|r| r.name.to_lowercase().contains("owl"))); - } -} diff --git a/crates/owlry-core/src/providers/lua_provider.rs b/crates/owlry-core/src/providers/lua_provider.rs deleted file mode 100644 index 067a85e..0000000 --- a/crates/owlry-core/src/providers/lua_provider.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! LuaProvider - Bridge between Lua plugins and the Provider trait -//! -//! This module provides a `LuaProvider` struct that implements the `Provider` trait -//! by delegating to a Lua plugin's registered provider functions. - -use std::sync::{Arc, Mutex}; - -use crate::plugins::{LoadedPlugin, PluginItem, ProviderRegistration}; - -use super::{ItemSource, LaunchItem, Provider, ProviderType}; - -/// A provider backed by a Lua plugin -/// -/// This struct implements the `Provider` trait by calling into a Lua plugin's -/// `refresh` or `query` functions. -pub struct LuaProvider { - /// Provider registration info - registration: ProviderRegistration, - /// Reference to the loaded plugin (shared with other providers from same plugin). - /// Mutex serializes concurrent refresh calls; Arc allows sharing across threads. - plugin: Arc>, - /// Cached items from last refresh - items: Vec, -} - -impl LuaProvider { - /// Create a new LuaProvider - pub fn new(registration: ProviderRegistration, plugin: Arc>) -> Self { - Self { - registration, - plugin, - items: Vec::new(), - } - } - - /// Convert a PluginItem to a LaunchItem - fn convert_item(&self, item: PluginItem) -> LaunchItem { - if item.command.is_none() { - log::warn!("Plugin item '{}' has no command", item.name); - } - LaunchItem { - id: item.id, - name: item.name, - description: item.description, - icon: item.icon, - provider: ProviderType::Plugin(self.registration.type_id.clone()), - command: item.command.unwrap_or_default(), - terminal: item.terminal, - tags: item.tags, - source: ItemSource::ScriptPlugin, - } - } -} - -impl Provider for LuaProvider { - fn name(&self) -> &str { - &self.registration.name - } - - fn provider_type(&self) -> ProviderType { - ProviderType::Plugin(self.registration.type_id.clone()) - } - - fn refresh(&mut self) { - // Only refresh static providers - if !self.registration.is_static { - return; - } - - let plugin = self.plugin.lock().unwrap(); - match plugin.call_provider_refresh(&self.registration.name) { - Ok(items) => { - self.items = items.into_iter().map(|i| self.convert_item(i)).collect(); - log::debug!( - "[LuaProvider] '{}' refreshed with {} items", - self.registration.name, - self.items.len() - ); - } - Err(e) => { - log::error!( - "[LuaProvider] Failed to refresh '{}': {}", - self.registration.name, - e - ); - self.items.clear(); - } - } - } - - fn items(&self) -> &[LaunchItem] { - &self.items - } -} - -// LuaProvider is Send + Sync because: -// - Arc> is Send + Sync (LoadedPlugin: Send with mlua "send" feature) -// - All other fields are Send + Sync -// No unsafe impl needed. - -/// Create LuaProviders from all registered providers in a plugin -pub fn create_providers_from_plugin(plugin: Arc>) -> Vec> { - let registrations = { - let p = plugin.lock().unwrap(); - match p.get_provider_registrations() { - Ok(regs) => regs, - Err(e) => { - log::error!("[LuaProvider] Failed to get registrations: {}", e); - return Vec::new(); - } - } - }; - - registrations - .into_iter() - .map(|reg| { - let provider = LuaProvider::new(reg, plugin.clone()); - Box::new(provider) as Box - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - // Note: Full integration tests require a complete plugin setup - // These tests verify the basic structure - - #[test] - fn test_provider_type() { - let reg = ProviderRegistration { - name: "test".to_string(), - display_name: "Test".to_string(), - type_id: "test_provider".to_string(), - default_icon: "test-icon".to_string(), - is_static: true, - prefix: None, - }; - - // We can't easily create a mock LoadedPlugin, so just test the type - assert_eq!(reg.type_id, "test_provider"); - } -} diff --git a/crates/owlry-core/src/providers/mod.rs b/crates/owlry-core/src/providers/mod.rs index 0b24f96..3813e55 100644 --- a/crates/owlry-core/src/providers/mod.rs +++ b/crates/owlry-core/src/providers/mod.rs @@ -1,29 +1,18 @@ -// Core providers (no plugin equivalents) +// Core providers (compiled in) mod application; mod command; pub(crate) mod calculator; -pub(crate) mod config_editor; pub(crate) mod converter; pub(crate) mod system; -// Native plugin bridge -pub mod native_provider; - -// Lua plugin bridge (optional) -#[cfg(feature = "lua")] -pub mod lua_provider; - // Re-exports for core providers pub use application::ApplicationProvider; pub use command::CommandProvider; -// Re-export native provider for plugin loading -pub use native_provider::NativeProvider; - use chrono::Utc; use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::skim::SkimMatcherV2; -use log::{info, warn}; +use log::info; #[cfg(feature = "dev-logging")] use log::debug; @@ -32,7 +21,24 @@ use std::sync::{Arc, RwLock}; use crate::config::Config; use crate::data::FrecencyStore; -use crate::plugins::runtime_loader::LoadedRuntime; + +/// Where a provider sits in the UI. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderPosition { + /// Normal results list. + Normal, + /// Widget — rendered at the top of the results, outside the normal flow. + Widget, +} + +impl ProviderPosition { + pub fn as_str(&self) -> &'static str { + match self { + ProviderPosition::Normal => "normal", + ProviderPosition::Widget => "widget", + } + } +} /// Metadata descriptor for an available provider (used by IPC/daemon API) #[derive(Debug, Clone)] @@ -49,11 +55,9 @@ pub struct ProviderDescriptor { /// Trust level of a [`LaunchItem`]'s command, used to gate `sh -c` execution. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ItemSource { - /// Built-in provider compiled into owlry-core (trusted). + /// Built-in provider compiled into the binary (trusted). Core, - /// Native plugin (.so from /usr/lib/owlry/plugins/) — trusted at install time. - NativePlugin, - /// Script plugin (Lua/Rune from ~/.config/owlry/plugins/) — user-installed, untrusted. + /// Script-defined provider (Lua, in Phase 3+) — user-installed, untrusted. ScriptPlugin, } @@ -61,7 +65,6 @@ impl ItemSource { pub fn as_str(&self) -> &'static str { match self { ItemSource::Core => "core", - ItemSource::NativePlugin => "native_plugin", ItemSource::ScriptPlugin => "script_plugin", } } @@ -71,7 +74,6 @@ impl std::str::FromStr for ItemSource { type Err = (); fn from_str(s: &str) -> Result { match s { - "native_plugin" => Ok(ItemSource::NativePlugin), "script_plugin" => Ok(ItemSource::ScriptPlugin), _ => Ok(ItemSource::Core), } @@ -97,23 +99,15 @@ pub struct LaunchItem { /// Provider type identifier for filtering and badge display. /// -/// **Glossary:** -/// - *Provider*: An abstract source of [`LaunchItem`]s (interface). -/// - *Built-in provider*: A provider compiled into owlry-core (Application, Command). -/// - *Plugin*: External code (native `.so` or Lua/Rune script) loaded at runtime. -/// - *Plugin provider*: A provider registered by a plugin, identified by its `type_id`. -/// -/// All plugin-provided types use `Plugin(type_id)`. The core has no hardcoded -/// knowledge of individual plugin types — this keeps the core app extensible. +/// - `Application`, `Command`, `Dmenu`: built-in core providers +/// - `Plugin(type_id)`: any other provider (compiled-in feature module or +/// future Lua-registered provider). The `type_id` is the provider's +/// stable identifier (e.g. `"bookmarks"`, `"uuctl"`, `"calc"`). #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum ProviderType { - /// Built-in provider: desktop applications from XDG data directories. Application, - /// Built-in provider: shell commands from `$PATH`. Command, - /// Built-in provider: pipe-based input for dmenu compatibility (client-local only). Dmenu, - /// Plugin provider with its declared `type_id` (e.g. `"calc"`, `"weather"`, `"emoji"`). Plugin(String), } @@ -122,11 +116,9 @@ impl std::str::FromStr for ProviderType { fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { - // Core built-in providers "app" | "apps" | "application" | "applications" => Ok(ProviderType::Application), - "cmd" | "command" | "commands" => Ok(ProviderType::Command), + "cmd" | "cmds" | "command" | "commands" => Ok(ProviderType::Command), "dmenu" => Ok(ProviderType::Dmenu), - // Everything else is a plugin other => Ok(ProviderType::Plugin(other.to_string())), } } @@ -143,13 +135,18 @@ impl std::fmt::Display for ProviderType { } } -/// Trait for all search providers +/// Trait for all search providers. +/// +/// Static providers hold a refreshable item cache. Submenu and action support +/// are optional methods — implement them only if the provider produces +/// `SUBMENU:` items or handles plugin-defined action commands. pub trait Provider: Send + Sync { #[allow(dead_code)] fn name(&self) -> &str; fn provider_type(&self) -> ProviderType; fn refresh(&mut self); fn items(&self) -> &[LaunchItem]; + /// Short label for UI tab button (e.g., "Shutdown"). None = use default. fn tab_label(&self) -> Option<&str> { None @@ -158,12 +155,42 @@ pub trait Provider: Send + Sync { fn search_noun(&self) -> Option<&str> { None } + /// Optional search prefix (e.g. ":bm"). None = no prefix. + fn prefix(&self) -> Option<&str> { + None + } + /// Icon name (XDG icon theme). + fn icon(&self) -> &str { + "application-x-addon" + } + /// UI placement. + fn position(&self) -> ProviderPosition { + ProviderPosition::Normal + } + /// Priority hint for ordering. + fn priority(&self) -> u32 { + 0 + } + + /// Generate submenu actions for an item whose command starts with `SUBMENU:`. + /// `data` is everything after the type_id prefix. + /// Default: no submenu support. + fn submenu_actions(&self, _data: &str) -> Vec { + Vec::new() + } + + /// Handle a plugin-defined action command (e.g. `"POMODORO:start"`). + /// Returns true if the command was handled. + /// Default: no action support. + fn execute_action(&self, _command: &str) -> bool { + false + } } /// Trait for built-in providers that produce results per-keystroke. /// Unlike static `Provider`s which cache items via `refresh()`/`items()`, /// dynamic providers generate results on every query. -pub(crate) trait DynamicProvider: Send + Sync { +pub trait DynamicProvider: Send + Sync { #[allow(dead_code)] fn name(&self) -> &str; fn provider_type(&self) -> ProviderType; @@ -176,358 +203,73 @@ pub(crate) trait DynamicProvider: Send + Sync { } } -/// Manages all providers and handles searching +/// Manages all providers and handles searching. pub struct ProviderManager { - /// Core static providers (apps, commands, dmenu) + /// Static providers (apps, commands, systemd, etc.). providers: Vec>, - /// Built-in dynamic providers (calculator, converter) - /// These are queried per-keystroke, like native dynamic plugins + /// Dynamic providers (calculator, converter, websearch, filesearch). + /// Queried per-keystroke, not cached. builtin_dynamic: Vec>, - /// Static native plugin providers (need query() for submenu support) - static_native_providers: Vec, - /// Dynamic providers from native plugins (calculator, websearch, filesearch) - /// These are queried per-keystroke, not cached - dynamic_providers: Vec, - /// Widget providers from native plugins (weather, media, pomodoro) - /// These appear at the top of results - widget_providers: Vec, - /// Fuzzy matcher for search + /// Fuzzy matcher for search. matcher: SkimMatcherV2, - /// Loaded script runtimes (Lua, Rune) — must stay alive to keep Library handles - runtimes: Vec, - /// Type IDs of providers from script runtimes (for hot-reload removal) - runtime_type_ids: std::collections::HashSet, - /// Registry of native plugins that were loaded or suppressed at startup. - /// Used by `Request::PluginList` to report plugin status to the CLI. - pub plugin_registry: Vec, } impl ProviderManager { - /// Create a new ProviderManager with core providers and native plugins. + /// Create a new ProviderManager from a pre-built list of providers. /// - /// Core providers (e.g., ApplicationProvider, CommandProvider, DmenuProvider) are - /// passed in by the caller. Native plugins are categorized based on their declared - /// ProviderKind and ProviderPosition. + /// Used by tests and by the dmenu-mode client. The daemon uses + /// [`Self::new_with_config`] which builds the provider set from config. pub fn new( core_providers: Vec>, - native_providers: Vec, + dynamic: Vec>, ) -> Self { let mut manager = Self { providers: core_providers, - builtin_dynamic: Vec::new(), - static_native_providers: Vec::new(), - dynamic_providers: Vec::new(), - widget_providers: Vec::new(), + builtin_dynamic: dynamic, matcher: SkimMatcherV2::default(), - runtimes: Vec::new(), - runtime_type_ids: std::collections::HashSet::new(), - plugin_registry: Vec::new(), }; - - // Categorize native plugins based on their declared ProviderKind and ProviderPosition - for provider in native_providers { - let type_id = provider.type_id(); - - if provider.is_dynamic() { - info!( - "Registered dynamic provider: {} ({})", - provider.name(), - type_id - ); - manager.dynamic_providers.push(provider); - } else if provider.is_widget() { - info!( - "Registered widget provider: {} ({})", - provider.name(), - type_id - ); - manager.widget_providers.push(provider); - } else { - info!( - "Registered static provider: {} ({})", - provider.name(), - type_id - ); - manager.static_native_providers.push(provider); - } - } - - // Initial refresh manager.refresh_all(); - manager } - /// Create a self-contained ProviderManager from config. + /// Build a ProviderManager for the daemon, sourcing enabled providers from config. /// - /// Loads native plugins, creates core providers (Application + Command), - /// categorizes everything, and performs initial refresh. Used by the daemon - /// which doesn't have the UI-driven setup path from `app.rs`. + /// Only built-in / compiled-in providers are registered here. Future Lua-defined + /// providers (Phase 3+) are added via [`Self::add_provider`] after construction. pub fn new_with_config(config: Arc>) -> Self { - use crate::plugins::native_loader::NativePluginLoader; - - // Create core providers - let mut core_providers: Vec> = vec![ - Box::new(ApplicationProvider::new()), - Box::new(CommandProvider::new()), - ]; - - // Take a read lock once for configuration reads during setup. - let (disabled_plugins, calc_enabled, conv_enabled, sys_enabled) = match config.read() { + let (calc_enabled, conv_enabled, sys_enabled) = match config.read() { Ok(cfg) => ( - cfg.plugins.disabled_plugins.clone(), cfg.providers.calculator, cfg.providers.converter, cfg.providers.system, ), Err(_) => { - warn!("Config lock poisoned during provider init; using defaults"); - (Vec::new(), true, true, true) + log::warn!("Config lock poisoned during provider init; using defaults"); + (true, true, true) } }; - // Load native plugins - let mut loader = NativePluginLoader::new(); - loader.set_disabled(disabled_plugins); + let mut core_providers: Vec> = vec![ + Box::new(ApplicationProvider::new()), + Box::new(CommandProvider::new()), + ]; - let native_providers = match loader.discover() { - Ok(count) => { - if count == 0 { - info!("No native plugins found"); - Vec::new() - } else { - info!("Discovered {} native plugin(s)", count); - let plugins: Vec> = - loader.into_plugins(); - let mut providers = Vec::new(); - for plugin in plugins { - for provider_info in &plugin.providers { - let provider = - NativeProvider::new(Arc::clone(&plugin), provider_info.clone()); - info!( - "Created native provider: {} ({})", - provider.name(), - provider.type_id() - ); - providers.push(provider); - } - } - providers - } - } - Err(e) => { - log::warn!("Failed to discover native plugins: {}", e); - Vec::new() - } - }; - - // Load script runtimes (Lua, Rune) for user plugins - let mut runtime_providers: Vec> = Vec::new(); - let mut runtimes: Vec = Vec::new(); - let mut runtime_type_ids = std::collections::HashSet::new(); - let owlry_version = env!("CARGO_PKG_VERSION"); - - let skip_runtimes = std::env::var("OWLRY_SKIP_RUNTIMES").is_ok(); - if !skip_runtimes - && let Some(plugins_dir) = crate::paths::plugins_dir() - { - // Try Lua runtime - match LoadedRuntime::load_lua(&plugins_dir, owlry_version) { - Ok(rt) => { - info!("Loaded Lua runtime with {} provider(s)", rt.providers().len()); - for provider in rt.create_providers() { - let type_id = format!("{}", provider.provider_type()); - runtime_type_ids.insert(type_id); - runtime_providers.push(provider); - } - runtimes.push(rt); - } - Err(e) => { - info!("Lua runtime not available: {}", e); - } - } - - // Try Rune runtime - match LoadedRuntime::load_rune(&plugins_dir, owlry_version) { - Ok(rt) => { - info!("Loaded Rune runtime with {} provider(s)", rt.providers().len()); - for provider in rt.create_providers() { - let type_id = format!("{}", provider.provider_type()); - runtime_type_ids.insert(type_id); - runtime_providers.push(provider); - } - runtimes.push(rt); - } - Err(e) => { - info!("Rune runtime not available: {}", e); - } - } - } // skip_runtimes - - // Merge runtime providers into core providers - for provider in runtime_providers { - info!("Registered runtime provider: {}", provider.name()); - core_providers.push(provider); - } - - // Built-in dynamic providers - let mut builtin_dynamic: Vec> = Vec::new(); - - if calc_enabled { - builtin_dynamic.push(Box::new(calculator::CalculatorProvider)); - info!("Registered built-in calculator provider"); - } - - if conv_enabled { - builtin_dynamic.push(Box::new(converter::ConverterProvider::new())); - info!("Registered built-in converter provider"); - } - - // Config editor — always enabled; shares the same Arc> - builtin_dynamic.push(Box::new(config_editor::ConfigProvider::new(Arc::clone(&config)))); - info!("Registered built-in config editor provider"); - - // Built-in static providers if sys_enabled { core_providers.push(Box::new(system::SystemProvider::new())); info!("Registered built-in system provider"); } - // Compute built-in type IDs to detect conflicts with native plugins. - // A native plugin whose type_id matches a built-in provider would - // produce duplicate results, so we skip it. - let builtin_ids: std::collections::HashSet = { - let mut ids = std::collections::HashSet::new(); - // Dynamic built-ins (calculator, converter) - for p in &builtin_dynamic { - if let ProviderType::Plugin(id) = p.provider_type() { - ids.insert(id); - } - } - // Static built-ins added to core_providers (e.g. system) - for p in &core_providers { - if let ProviderType::Plugin(id) = p.provider_type() { - ids.insert(id); - } - } - ids - }; - - let mut suppressed_registry: Vec = Vec::new(); - let native_providers: Vec = native_providers - .into_iter() - .filter(|provider| { - let type_id = provider.type_id(); - if builtin_ids.contains(type_id) { - log::warn!( - "Native plugin '{}' suppressed — a built-in provider with the same type ID exists", - type_id - ); - suppressed_registry.push(crate::ipc::PluginEntry { - id: provider.plugin_id().to_string(), - name: provider.plugin_name().to_string(), - version: provider.plugin_version().to_string(), - runtime: "native".to_string(), - status: "suppressed".to_string(), - status_detail: format!( - "built-in provider '{}' takes precedence", - type_id - ), - providers: vec![type_id.to_string()], - }); - false - } else { - true - } - }) - .collect(); - - // Capture active native plugin entries before ownership moves into Self::new(). - let active_registry: Vec = native_providers - .iter() - .map(|p| crate::ipc::PluginEntry { - id: p.plugin_id().to_string(), - name: p.plugin_name().to_string(), - version: p.plugin_version().to_string(), - runtime: "native".to_string(), - status: "active".to_string(), - status_detail: String::new(), - providers: vec![p.type_id().to_string()], - }) - .collect(); - - let mut manager = Self::new(core_providers, native_providers); - manager.builtin_dynamic = builtin_dynamic; - manager.runtimes = runtimes; - manager.runtime_type_ids = runtime_type_ids; - - manager.plugin_registry = active_registry; - manager.plugin_registry.extend(suppressed_registry); - - manager - } - - /// Reload all script runtime providers (called by filesystem watcher) - pub fn reload_runtimes(&mut self) { - use crate::plugins::runtime_loader::LoadedRuntime; - - // Remove old runtime providers from the core providers list - self.providers.retain(|p| { - let type_str = format!("{}", p.provider_type()); - !self.runtime_type_ids.contains(&type_str) - }); - - // Drop old runtimes. Panics here will poison the ProviderManager RwLock, - // which is caught and reported by the watcher thread (see plugins/watcher.rs). - info!("Dropping old runtimes before reload"); - let old_runtimes = std::mem::take(&mut self.runtimes); - drop(old_runtimes); - self.runtime_type_ids.clear(); - - let owlry_version = env!("CARGO_PKG_VERSION"); - let plugins_dir = match crate::paths::plugins_dir() { - Some(d) => d, - None => return, - }; - - // Reload Lua runtime - match LoadedRuntime::load_lua(&plugins_dir, owlry_version) { - Ok(rt) => { - info!("Reloaded Lua runtime with {} provider(s)", rt.providers().len()); - for provider in rt.create_providers() { - let type_id = format!("{}", provider.provider_type()); - self.runtime_type_ids.insert(type_id); - self.providers.push(provider); - } - self.runtimes.push(rt); - } - Err(e) => { - info!("Lua runtime not available on reload: {}", e); - } + let mut builtin_dynamic: Vec> = Vec::new(); + if calc_enabled { + builtin_dynamic.push(Box::new(calculator::CalculatorProvider)); + info!("Registered built-in calculator provider"); + } + if conv_enabled { + builtin_dynamic.push(Box::new(converter::ConverterProvider::new())); + info!("Registered built-in converter provider"); } - // 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"); + Self::new(core_providers, builtin_dynamic) } #[allow(dead_code)] @@ -538,7 +280,6 @@ impl ProviderManager { } pub fn refresh_all(&mut self) { - // Refresh core providers (apps, commands) for provider in &mut self.providers { provider.refresh(); info!( @@ -547,153 +288,77 @@ impl ProviderManager { provider.items().len() ); } - - // Refresh static native providers (clipboard, emoji, ssh, etc.) - for provider in &mut self.static_native_providers { - provider.refresh(); - info!( - "Static provider '{}' loaded {} items", - provider.name(), - provider.items().len() - ); - } - - // Widget providers are refreshed separately to avoid blocking startup - // Call refresh_widgets() after window is shown - - // Dynamic providers don't need refresh (they query on demand) + // Dynamic providers don't need refresh (they query on demand). } - /// Refresh widget providers (weather, media, pomodoro) - /// Call this separately from refresh_all() to avoid blocking startup - /// since widgets may make network requests or spawn processes - pub fn refresh_widgets(&mut self) { - for provider in &mut self.widget_providers { - provider.refresh(); - info!( - "Widget '{}' loaded {} items", - provider.name(), - provider.items().len() - ); - } + /// Register an additional provider at runtime. + /// + /// Used by Phase 3+ Lua config to add user-defined providers after the + /// daemon has booted. The provider's `refresh()` is called immediately. + #[allow(dead_code)] + pub fn add_provider(&mut self, mut provider: Box) { + provider.refresh(); + info!("Registered provider: {}", provider.name()); + self.providers.push(provider); } - /// Find a native provider by type ID - /// Searches in all native provider lists (static, dynamic, widget) - pub fn find_native_provider(&self, type_id: &str) -> Option<&NativeProvider> { - // Check static native providers first (clipboard, emoji, ssh, systemd, etc.) - if let Some(p) = self - .static_native_providers - .iter() - .find(|p| p.type_id() == type_id) - { - return Some(p); - } - // Check widget providers (pomodoro, weather, media) - if let Some(p) = self - .widget_providers - .iter() - .find(|p| p.type_id() == type_id) - { - return Some(p); - } - // Then dynamic providers (calc, websearch, filesearch) - self.dynamic_providers - .iter() - .find(|p| p.type_id() == type_id) - } - - /// Execute a plugin action command - /// Command format: PLUGIN_ID:action_data (e.g., "POMODORO:start", "SYSTEMD:unit:restart") - /// Returns true if the command was handled by a plugin + /// Execute a plugin-defined action command. + /// + /// Command format: `PLUGIN_ID:action_data` (e.g. `"POMODORO:start"`). + /// Returns true if a provider handled the command. pub fn execute_plugin_action(&self, command: &str) -> bool { - // Parse command format: PLUGIN_ID:action_data - if let Some(colon_pos) = command.find(':') { - let plugin_id = &command[..colon_pos]; - let action = command; // Pass full command to plugin - - // Find provider by type ID (case-insensitive for convenience) - let type_id = plugin_id.to_lowercase(); - - if let Some(provider) = self.find_native_provider(&type_id) { - provider.execute_action(action); + for provider in &self.providers { + if provider.execute_action(command) { return true; } } - - // Check built-in dynamic providers for provider in &self.builtin_dynamic { if provider.execute_action(command) { return true; } } - false } - /// Add a dynamic provider (e.g., from a Lua plugin) - #[allow(dead_code)] - pub fn add_provider(&mut self, provider: Box) { - info!("Added plugin provider: {}", provider.name()); - self.providers.push(provider); - } - - /// Add multiple providers at once (for batch plugin loading) - #[allow(dead_code)] - pub fn add_providers(&mut self, providers: Vec>) { - for provider in providers { - self.add_provider(provider); - } - } - - /// Iterate over all static provider items (core + native static plugins) - fn all_static_items(&self) -> impl Iterator { - self.providers.iter().flat_map(|p| p.items().iter()).chain( - self.static_native_providers - .iter() - .flat_map(|p| p.items().iter()), - ) - } - #[allow(dead_code)] pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> { if query.is_empty() { - // Return recent/popular items when query is empty return self - .all_static_items() + .providers + .iter() + .flat_map(|p| p.items().iter().cloned()) .take(max_results) - .map(|item| (item.clone(), 0)) + .map(|item| (item, 0)) .collect(); } let mut results: Vec<(LaunchItem, i64)> = self - .all_static_items() + .providers + .iter() + .flat_map(|p| p.items().iter()) .filter_map(|item| { - // Match against name and description let name_score = self.matcher.fuzzy_match(&item.name, query); let desc_score = item .description .as_ref() .and_then(|d| self.matcher.fuzzy_match(d, query)); - let score = match (name_score, desc_score) { (Some(n), Some(d)) => Some(n.max(d)), (Some(n), None) => Some(n), - (None, Some(d)) => Some(d / 2), // Lower weight for description matches + (None, Some(d)) => Some(d / 2), (None, None) => None, }; - score.map(|s| (item.clone(), s)) }) .collect(); - // Sort by score (descending) results.sort_by(|a, b| b.1.cmp(&a.1)); results.truncate(max_results); results } - /// Search with provider filtering + /// Search with provider filtering. + #[allow(dead_code)] pub fn search_filtered( &self, query: &str, @@ -701,23 +366,12 @@ impl ProviderManager { filter: &crate::filter::ProviderFilter, tag_filter: Option<&str>, ) -> Vec<(LaunchItem, i64)> { - // Collect items from core providers - let core_items = self + let all_items = self .providers .iter() .filter(|p| filter.is_active(p.provider_type())) - .flat_map(|p| p.items().iter().cloned()); - - // Collect items from static native providers - let native_items = self - .static_native_providers - .iter() - .filter(|p| filter.is_active(p.provider_type())) - .flat_map(|p| p.items().iter().cloned()); - - let all_items = core_items.chain(native_items).filter(|item| { - tag_filter.is_none_or(|t| item.tags.iter().any(|it| it == t)) - }); + .flat_map(|p| p.items().iter().cloned()) + .filter(|item| tag_filter.is_none_or(|t| item.tags.iter().any(|it| it == t))); if query.is_empty() { return all_items.take(max_results).map(|item| (item, 0)).collect(); @@ -730,14 +384,12 @@ impl ProviderManager { .description .as_ref() .and_then(|d| self.matcher.fuzzy_match(d, query)); - let score = match (name_score, desc_score) { (Some(n), Some(d)) => Some(n.max(d)), (Some(n), None) => Some(n), (None, Some(d)) => Some(d / 2), (None, None) => None, }; - score.map(|s| (item, s)) }) .collect(); @@ -747,7 +399,7 @@ impl ProviderManager { results } - /// Search with frecency boosting, dynamic providers, and tag filtering + /// Search with frecency boosting, dynamic providers, and tag filtering. pub fn search_with_frecency( &self, query: &str, @@ -766,14 +418,12 @@ impl ProviderManager { let now = Utc::now(); let mut results: Vec<(LaunchItem, i64)> = Vec::new(); - // Add widget items first (highest priority) - only when: - // 1. No specific filter prefix is active - // 2. Query is empty (user hasn't started searching) - // This keeps widgets visible on launch but hides them during active search - // Widgets are always visible regardless of filter settings (they declare position via API) + // Widget providers contribute on empty query (no prefix active). if filter.active_prefix().is_none() && query.is_empty() { - // Widget priority comes from plugin-declared priority field - for provider in &self.widget_providers { + for provider in &self.providers { + if provider.position() != ProviderPosition::Widget { + continue; + } let base_score = provider.priority() as i64; for (idx, item) in provider.items().iter().enumerate() { results.push((item.clone(), base_score - idx as i64)); @@ -781,74 +431,35 @@ impl ProviderManager { } } - // Query dynamic providers (calculator, websearch, filesearch) - // Only query if: - // 1. Their specific filter is active (e.g., :file prefix or Files tab selected), OR - // 2. No specific single-mode filter is active (showing all providers) + // Dynamic providers (calculator, converter, etc.) — only when there's a query. if !query.is_empty() { - for provider in &self.dynamic_providers { - // Skip if this provider type is explicitly filtered out - if !filter.is_active(provider.provider_type()) { - continue; - } - let dynamic_results = provider.query(query); - // Priority comes from plugin-declared priority field - let base_score = provider.priority() as i64; - - // Auto-detect plugins (calc, conv) get a grouping bonus so - // all their results stay together above generic search results - let grouping_bonus: i64 = match provider.provider_type() { - ProviderType::Plugin(ref id) - if matches!(id.as_str(), "calc" | "conv") => - { - 10_000 - } - _ => 0, - }; - - for (idx, item) in dynamic_results.into_iter().enumerate() { - results.push((item, base_score + grouping_bonus - idx as i64)); - } - } - - // Built-in dynamic providers (calculator, converter) for provider in &self.builtin_dynamic { if !filter.is_active(provider.provider_type()) { continue; } let dynamic_results = provider.query(query); let base_score = provider.priority() as i64; - let grouping_bonus: i64 = match provider.provider_type() { - ProviderType::Plugin(ref id) - if matches!(id.as_str(), "calc" | "conv") => - { + ProviderType::Plugin(ref id) if matches!(id.as_str(), "calc" | "conv") => { 10_000 } _ => 0, }; - for (idx, item) in dynamic_results.into_iter().enumerate() { results.push((item, base_score + grouping_bonus - idx as i64)); } } } - // Empty query (after checking special providers) - return frecency-sorted items + // Empty query (after widgets) — frecency-sorted items. if query.is_empty() { let mut scored_refs: Vec<(&LaunchItem, i64)> = self .providers .iter() + .filter(|p| p.position() != ProviderPosition::Widget) .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| { - // Apply tag filter if present if let Some(tag) = tag_filter { item.tags.iter().any(|t| t.to_lowercase().contains(tag)) } else { @@ -862,43 +473,36 @@ impl ProviderManager { }) .collect(); - // Partial sort: O(n) average to find top max_results, then O(k log k) to order them if scored_refs.len() > max_results { scored_refs.select_nth_unstable_by(max_results, |a, b| b.1.cmp(&a.1)); scored_refs.truncate(max_results); } scored_refs.sort_by(|a, b| b.1.cmp(&a.1)); - // Clone only the survivors results.extend(scored_refs.into_iter().map(|(item, score)| (item.clone(), score))); results.sort_by(|a, b| b.1.cmp(&a.1)); results.truncate(max_results); return results; } - // Regular search with frecency boost and tag matching - // Helper closure for scoring items + // Regular search with frecency boost and tag matching. let score_item = |item: &LaunchItem| -> Option { - // Apply tag filter if present if let Some(tag) = tag_filter && !item.tags.iter().any(|t| t.to_lowercase().contains(tag)) { return None; } - let name_score = self.matcher.fuzzy_match(&item.name, query); let desc_score = item .description .as_ref() .and_then(|d| self.matcher.fuzzy_match(d, query)); - - // Also match against tags (lower weight) let tag_score = item .tags .iter() .filter_map(|t| self.matcher.fuzzy_match(t, query)) .max() - .map(|s| s / 3); // Lower weight for tag matches + .map(|s| s / 3); let base_score = match (name_score, desc_score, tag_score) { (Some(n), Some(d), Some(t)) => Some(n.max(d).max(t)), @@ -914,8 +518,6 @@ impl ProviderManager { 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; - - // Exact name match bonus — apps get a higher boost let exact_match_boost = if item.name.eq_ignore_ascii_case(query) { match &item.provider { ProviderType::Application => 50_000, @@ -924,15 +526,15 @@ impl ProviderManager { } else { 0 }; - s + frecency_boost + exact_match_boost }) }; - // Score static items by reference (no cloning) let mut scored_refs: Vec<(&LaunchItem, i64)> = Vec::new(); - for provider in &self.providers { + if provider.position() == ProviderPosition::Widget { + continue; + } if !filter.is_active(provider.provider_type()) { continue; } @@ -943,28 +545,13 @@ impl ProviderManager { } } - 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))); - - // Final sort merges dynamic results (already in `results`) with static top-N results.sort_by(|a, b| b.1.cmp(&a.1)); results.truncate(max_results); @@ -988,103 +575,56 @@ impl ProviderManager { results } - /// Get all available provider types (for UI tabs) + /// Get all available provider types (for UI tabs). #[allow(dead_code)] pub fn available_provider_types(&self) -> Vec { - self.providers - .iter() - .map(|p| p.provider_type()) - .chain( - self.static_native_providers - .iter() - .map(|p| p.provider_type()), - ) - .collect() + self.providers.iter().map(|p| p.provider_type()).collect() } - /// Get descriptors for all registered providers (core + native plugins). + /// Get descriptors for all registered providers. /// /// Used by the IPC server to report what providers are available to clients. pub fn available_providers(&self) -> Vec { let mut descs = Vec::new(); - // Core providers for provider in &self.providers { - let (id, prefix, icon) = match provider.provider_type() { + let (id, default_prefix, default_icon) = match provider.provider_type() { ProviderType::Application => ( "app".to_string(), Some(":app".to_string()), - "application-x-executable".to_string(), + "application-x-executable", ), ProviderType::Command => ( "cmd".to_string(), Some(":cmd".to_string()), - "utilities-terminal".to_string(), + "utilities-terminal", ), - ProviderType::Dmenu => { - ("dmenu".to_string(), None, "view-list-symbolic".to_string()) - } - ProviderType::Plugin(type_id) => (type_id, None, "application-x-addon".to_string()), + ProviderType::Dmenu => ("dmenu".to_string(), None, "view-list-symbolic"), + ProviderType::Plugin(type_id) => (type_id, None, "application-x-addon"), }; descs.push(ProviderDescriptor { id, name: provider.name().to_string(), - prefix, - icon, - position: "normal".to_string(), + prefix: provider.prefix().map(String::from).or(default_prefix), + icon: { + let trait_icon = provider.icon(); + if trait_icon == "application-x-addon" { + default_icon.to_string() + } else { + trait_icon.to_string() + } + }, + position: provider.position().as_str().to_string(), tab_label: provider.tab_label().map(String::from), search_noun: provider.search_noun().map(String::from), }); } - // Static native plugin providers - for provider in &self.static_native_providers { - descs.push(ProviderDescriptor { - id: provider.type_id().to_string(), - name: provider.name().to_string(), - prefix: provider.prefix().map(String::from), - icon: provider.icon().to_string(), - position: provider.position_str().to_string(), - tab_label: None, - search_noun: None, - }); - } - - // Dynamic native plugin providers - for provider in &self.dynamic_providers { - descs.push(ProviderDescriptor { - id: provider.type_id().to_string(), - name: provider.name().to_string(), - prefix: provider.prefix().map(String::from), - icon: provider.icon().to_string(), - position: provider.position_str().to_string(), - tab_label: None, - search_noun: None, - }); - } - - // Widget native plugin providers - for provider in &self.widget_providers { - descs.push(ProviderDescriptor { - id: provider.type_id().to_string(), - name: provider.name().to_string(), - prefix: provider.prefix().map(String::from), - icon: provider.icon().to_string(), - position: provider.position_str().to_string(), - tab_label: None, - search_noun: None, - }); - } - descs } /// Refresh a specific provider by its type_id. - /// - /// Searches core providers (by ProviderType string), static native providers, - /// and widget providers. Dynamic providers are skipped (they query on demand). pub fn refresh_provider(&mut self, provider_id: &str) { - // Check core providers for provider in &mut self.providers { let matches = match provider.provider_type() { ProviderType::Application => provider_id == "app", @@ -1094,93 +634,33 @@ impl ProviderManager { }; if matches { provider.refresh(); - info!("Refreshed core provider '{}'", provider.name()); + info!("Refreshed provider '{}'", provider.name()); return; } } - - // Check static native providers - for provider in &mut self.static_native_providers { - if provider.type_id() == provider_id { - provider.refresh(); - info!("Refreshed static provider '{}'", provider.name()); - return; - } - } - - // Check widget providers - for provider in &mut self.widget_providers { - if provider.type_id() == provider_id { - provider.refresh(); - info!("Refreshed widget provider '{}'", provider.name()); - return; - } - } - info!("Provider '{}' not found for refresh", provider_id); } - /// Get a widget item by type_id (e.g., "pomodoro", "weather", "media") - /// Returns the first item from the widget provider, if any - pub fn get_widget_item(&self, type_id: &str) -> Option { - self.widget_providers - .iter() - .find(|p| p.type_id() == type_id) - .and_then(|p| p.items().first().cloned()) - } - - /// Get all loaded widget provider type_ids - /// Returns an iterator over the type_ids of currently loaded widget providers - pub fn widget_type_ids(&self) -> impl Iterator { - self.widget_providers.iter().map(|p| p.type_id()) - } - - /// Query a plugin for submenu actions + /// Query a provider for submenu actions. /// - /// This is used when a user selects a SUBMENU:plugin_id:data item. - /// The plugin is queried with "?SUBMENU:data" and returns action items. - /// - /// Returns (display_name, actions) where display_name is the item name - /// and actions are the submenu items returned by the plugin. + /// Called when a user selects a `SUBMENU:plugin_id:data` item. The provider's + /// `submenu_actions(data)` is invoked to produce the action list. pub fn query_submenu_actions( &self, plugin_id: &str, data: &str, display_name: &str, ) -> Option<(String, Vec)> { - // Build the submenu query - let submenu_query = format!("?SUBMENU:{}", data); - #[cfg(feature = "dev-logging")] - debug!( - "[Submenu] Querying plugin '{}' with: {}", - plugin_id, submenu_query - ); + debug!("[Submenu] Querying provider '{}' with data: {}", plugin_id, data); - // Search in static native providers (clipboard, emoji, ssh, systemd, etc.) - for provider in &self.static_native_providers { - if provider.type_id() == plugin_id { - let actions = provider.query(&submenu_query); - if !actions.is_empty() { - return Some((display_name.to_string(), actions)); - } - } - } - - // Search in dynamic providers - for provider in &self.dynamic_providers { - if provider.type_id() == plugin_id { - let actions = provider.query(&submenu_query); - if !actions.is_empty() { - return Some((display_name.to_string(), actions)); - } - } - } - - // Search in widget providers - for provider in &self.widget_providers { - if provider.type_id() == plugin_id { - let actions = provider.query(&submenu_query); + for provider in &self.providers { + let matches = match provider.provider_type() { + ProviderType::Plugin(ref id) => id == plugin_id, + _ => false, + }; + if matches { + let actions = provider.submenu_actions(data); if !actions.is_empty() { return Some((display_name.to_string(), actions)); } @@ -1188,11 +668,7 @@ impl ProviderManager { } #[cfg(feature = "dev-logging")] - debug!( - "[Submenu] No submenu actions found for plugin '{}'", - plugin_id - ); - + debug!("[Submenu] No submenu actions for provider '{}'", plugin_id); None } } @@ -1306,11 +782,9 @@ mod tests { let providers: Vec> = vec![Box::new(app), Box::new(cmd)]; let mut pm = ProviderManager::new(providers, Vec::new()); - // refresh_all was called during construction, now refresh individual pm.refresh_provider("app"); pm.refresh_provider("cmd"); - // Just verifying it doesn't panic; can't easily inspect refresh_count - // through Box + // Just verifying it doesn't panic. } #[test] @@ -1321,7 +795,6 @@ mod tests { ))]; let mut pm = ProviderManager::new(providers, Vec::new()); pm.refresh_provider("nonexistent"); - // Should complete without panicking } #[test] @@ -1339,5 +812,4 @@ mod tests { assert_eq!(results.len(), 1); assert_eq!(results[0].0.name, "Firefox"); } - } diff --git a/crates/owlry-core/src/providers/native_provider.rs b/crates/owlry-core/src/providers/native_provider.rs deleted file mode 100644 index 3d6061c..0000000 --- a/crates/owlry-core/src/providers/native_provider.rs +++ /dev/null @@ -1,220 +0,0 @@ -//! Native Plugin Provider Bridge -//! -//! This module provides a bridge between native plugins (compiled .so files) -//! and the core Provider trait used by ProviderManager. -//! -//! Native plugins are loaded from `/usr/lib/owlry/plugins/` as `.so` files -//! and provide search providers via an ABI-stable interface. - -use std::sync::Arc; - -use log::debug; -use owlry_plugin_api::{ - PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition, -}; - -use super::{ItemSource, LaunchItem, Provider, ProviderType}; -use crate::plugins::native_loader::NativePlugin; - -/// A provider backed by a native plugin -/// -/// This wraps a native plugin's provider and implements the core Provider trait, -/// allowing native plugins to be used seamlessly with the existing ProviderManager. -pub struct NativeProvider { - /// The native plugin (shared reference since multiple providers may use same plugin) - plugin: Arc, - /// Provider metadata - info: ProviderInfo, - /// Handle to the provider state in the plugin - handle: ProviderHandle, - /// Cached items (for static providers) - items: Vec, -} - -impl NativeProvider { - /// Create a new native provider - pub fn new(plugin: Arc, info: ProviderInfo) -> Self { - let handle = plugin.init_provider(info.id.as_str()); - - Self { - plugin, - info, - handle, - items: Vec::new(), - } - } - - /// Get the ProviderType for this native provider - /// All native plugins return Plugin(type_id) - the core has no hardcoded plugin types - fn get_provider_type(&self) -> ProviderType { - ProviderType::Plugin(self.info.type_id.to_string()) - } - - /// The ID of the plugin that owns this provider. - pub fn plugin_id(&self) -> &str { - self.plugin.id() - } - - /// The human-readable name of the plugin that owns this provider. - pub fn plugin_name(&self) -> &str { - self.plugin.name() - } - - /// The version string of the plugin that owns this provider. - pub fn plugin_version(&self) -> &str { - self.plugin.info.version.as_str() - } - - /// Convert a plugin API item to a core LaunchItem - fn convert_item(&self, item: ApiPluginItem) -> LaunchItem { - LaunchItem { - id: item.id.to_string(), - name: item.name.to_string(), - description: item.description.as_ref().map(|s| s.to_string()).into(), - icon: item.icon.as_ref().map(|s| s.to_string()).into(), - provider: self.get_provider_type(), - command: item.command.to_string(), - terminal: item.terminal, - tags: item.keywords.iter().map(|s| s.to_string()).collect(), - source: ItemSource::NativePlugin, - } - } - - /// Query the provider - /// - /// For dynamic providers, this is called per-keystroke. - /// For static providers, returns cached items unless query is a special command - /// (submenu queries `?SUBMENU:` or action commands `!ACTION:`). - pub fn query(&self, query: &str) -> Vec { - // Special queries (submenu, actions) should always be forwarded to the plugin - let is_special_query = query.starts_with("?SUBMENU:") || query.starts_with("!"); - - if self.info.provider_type != ProviderKind::Dynamic && !is_special_query { - return self.items.clone(); - } - - let api_items = self.plugin.query_provider(self.handle, query); - api_items - .into_iter() - .map(|item| self.convert_item(item)) - .collect() - } - - /// Check if this provider has a prefix that matches the query - #[allow(dead_code)] - pub fn matches_prefix(&self, query: &str) -> bool { - match self.info.prefix.as_ref().into_option() { - Some(prefix) => query.starts_with(prefix.as_str()), - None => false, - } - } - - /// Get the prefix for this provider (if any) - #[allow(dead_code)] - pub fn prefix(&self) -> Option<&str> { - self.info.prefix.as_ref().map(|s| s.as_str()).into() - } - - /// Check if this is a dynamic provider - #[allow(dead_code)] - pub fn is_dynamic(&self) -> bool { - self.info.provider_type == ProviderKind::Dynamic - } - - /// Get the provider type ID (e.g., "calc", "clipboard", "weather") - pub fn type_id(&self) -> &str { - self.info.type_id.as_str() - } - - /// Check if this is a widget provider (appears at top of results) - pub fn is_widget(&self) -> bool { - self.info.position == ProviderPosition::Widget - } - - /// Get the provider's priority for result ordering - /// Higher values appear first in results - pub fn priority(&self) -> i32 { - self.info.priority - } - - /// Get the provider's default icon name - pub fn icon(&self) -> &str { - self.info.icon.as_str() - } - - /// Get the provider's display position as a string - pub fn position_str(&self) -> &str { - match self.info.position { - ProviderPosition::Widget => "widget", - ProviderPosition::Normal => "normal", - } - } - - /// Execute an action command on the provider - /// Uses query with "!" prefix to trigger action handling in the plugin - pub fn execute_action(&self, action: &str) { - let action_query = format!("!{}", action); - self.plugin.query_provider(self.handle, &action_query); - } -} - -impl Provider for NativeProvider { - fn name(&self) -> &str { - self.info.name.as_str() - } - - fn provider_type(&self) -> ProviderType { - self.get_provider_type() - } - - fn refresh(&mut self) { - // Only refresh static providers - if self.info.provider_type != ProviderKind::Static { - return; - } - - debug!("Refreshing native provider '{}'", self.info.name.as_str()); - - let api_items = self.plugin.refresh_provider(self.handle); - let items: Vec = api_items - .into_iter() - .map(|item| self.convert_item(item)) - .collect(); - - debug!( - "Native provider '{}' loaded {} items", - self.info.name.as_str(), - items.len() - ); - - self.items = items; - } - - fn items(&self) -> &[LaunchItem] { - &self.items - } -} - -impl Drop for NativeProvider { - fn drop(&mut self) { - // Clean up the provider handle - self.plugin.drop_provider(self.handle); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // Note: Full testing requires actual .so plugins, which we'll test - // via integration tests. Unit tests here focus on the conversion logic. - - #[test] - fn test_provider_type_conversion() { - // Test that type_id is correctly converted to ProviderType::Plugin - let type_id = "calculator"; - let provider_type = ProviderType::Plugin(type_id.to_string()); - - assert_eq!(format!("{}", provider_type), "calculator"); - } -} diff --git a/crates/owlry-core/src/server.rs b/crates/owlry-core/src/server.rs index e861054..86df4a4 100644 --- a/crates/owlry-core/src/server.rs +++ b/crates/owlry-core/src/server.rs @@ -111,8 +111,6 @@ impl Server { info!("IPC server listening on {:?}", socket_path); let config = Arc::new(RwLock::new(Config::load_or_default())); - // Share config with native plugin loader so plugins can read their own config sections. - crate::plugins::native_loader::set_shared_config(Arc::clone(&config)); let provider_manager = ProviderManager::new_with_config(Arc::clone(&config)); let frecency = FrecencyStore::new(); @@ -127,9 +125,6 @@ impl Server { /// Accept connections in a loop, spawning a thread per client. pub fn run(&self) -> io::Result<()> { - // Start filesystem watcher for user plugin hot-reload - crate::plugins::watcher::start_watching(Arc::clone(&self.provider_manager)); - // SIGHUP handler: reload config from disk into the shared Arc>. { use signal_hook::consts::SIGHUP; @@ -419,15 +414,6 @@ impl Server { } } - Request::PluginList => { - let pm_guard = match pm.read() { - Ok(g) => g, - Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() }, - }; - Response::PluginList { - entries: pm_guard.plugin_registry.clone(), - } - } } } } diff --git a/crates/owlry-lua/Cargo.toml b/crates/owlry-lua/Cargo.toml deleted file mode 100644 index a17ff4e..0000000 --- a/crates/owlry-lua/Cargo.toml +++ /dev/null @@ -1,51 +0,0 @@ -[package] -name = "owlry-lua" -version = "1.1.5" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "Lua runtime for owlry plugins - enables loading user-created Lua plugins" -keywords = ["owlry", "plugin", "lua", "runtime"] -categories = ["development-tools"] - -[lib] -crate-type = ["cdylib"] # Compile as dynamic library (.so) - -[features] -# Bundle Lua 5.4 from source (no system lua dep). Enabled by default for dev -# and CI builds. Distribution packages should disable this and add lua54 as a -# system dependency instead. -default = ["vendored"] -vendored = ["mlua/vendored"] - -[dependencies] -# Plugin API for owlry (shared types) -owlry-plugin-api = { path = "../owlry-plugin-api" } - -# ABI-stable types -abi_stable = "0.11" - -# Lua runtime -mlua = { version = "0.11", features = ["lua54", "send", "serialize"] } - -# Plugin manifest parsing -toml = "0.8" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -# Version compatibility -semver = "1" - -# Logging -log = "0.4" - - -# Date/time for os.date -chrono = "0.4" - -# XDG paths -dirs = "5.0" - -[dev-dependencies] -tempfile = "3" diff --git a/crates/owlry-lua/src/api/mod.rs b/crates/owlry-lua/src/api/mod.rs deleted file mode 100644 index 6efaee6..0000000 --- a/crates/owlry-lua/src/api/mod.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Lua API implementations for plugins -//! -//! This module provides the `owlry` global table and its submodules -//! that plugins can use to interact with owlry. - -mod provider; -mod utils; - -use mlua::{Lua, Result as LuaResult}; -use owlry_plugin_api::PluginItem; - -use crate::loader::ProviderRegistration; - -/// Register all owlry APIs in the Lua runtime -pub fn register_apis(lua: &Lua, plugin_dir: &std::path::Path, plugin_id: &str) -> LuaResult<()> { - let globals = lua.globals(); - - // Create the main owlry table - let owlry = lua.create_table()?; - - // Register utility APIs (log, path, fs, json) - utils::register_log_api(lua, &owlry)?; - utils::register_path_api(lua, &owlry, plugin_dir)?; - utils::register_fs_api(lua, &owlry, plugin_dir)?; - utils::register_json_api(lua, &owlry)?; - - // Register provider API - provider::register_provider_api(lua, &owlry)?; - - // Set owlry as global - globals.set("owlry", owlry)?; - - // Suppress unused warnings - let _ = plugin_id; - - Ok(()) -} - -/// Get provider registrations from the Lua runtime -pub fn get_provider_registrations(lua: &Lua) -> LuaResult> { - provider::get_registrations(lua) -} - -/// Call a provider's refresh function -pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult> { - provider::call_refresh(lua, provider_name) -} - -/// Call a provider's query function -pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult> { - provider::call_query(lua, provider_name, query) -} - -/// Call the global `refresh()` function (for manifest-declared providers) -pub fn call_global_refresh(lua: &Lua) -> LuaResult> { - provider::call_global_refresh(lua) -} diff --git a/crates/owlry-lua/src/api/provider.rs b/crates/owlry-lua/src/api/provider.rs deleted file mode 100644 index 542e51f..0000000 --- a/crates/owlry-lua/src/api/provider.rs +++ /dev/null @@ -1,271 +0,0 @@ -//! Provider registration API for Lua plugins - -use mlua::{Function, Lua, Result as LuaResult, Table, Value}; -use owlry_plugin_api::PluginItem; -use std::cell::RefCell; - -use crate::loader::ProviderRegistration; - -thread_local! { - static REGISTRATIONS: RefCell> = const { RefCell::new(Vec::new()) }; -} - -/// Register the provider API in the owlry table -pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { - let provider = lua.create_table()?; - - // owlry.provider.register(config) - provider.set("register", lua.create_function(register_provider)?)?; - - owlry.set("provider", provider)?; - Ok(()) -} - -/// Implementation of owlry.provider.register() -fn register_provider(lua: &Lua, config: Table) -> LuaResult<()> { - let name: String = config.get("name")?; - let display_name: String = config - .get::>("display_name")? - .unwrap_or_else(|| name.clone()); - let type_id: String = config - .get::>("type_id")? - .unwrap_or_else(|| name.replace('-', "_")); - let default_icon: String = config - .get::>("default_icon")? - .unwrap_or_else(|| "application-x-addon".to_string()); - let prefix: Option = config.get("prefix")?; - let tab_label: Option = config.get("tab_label")?; - let search_noun: Option = config.get("search_noun")?; - - // Check if it's a dynamic provider (has query function) or static (has refresh) - let has_query: bool = config.contains_key("query")?; - let has_refresh: bool = config.contains_key("refresh")?; - - if !has_query && !has_refresh { - return Err(mlua::Error::external( - "Provider must have either 'refresh' or 'query' function", - )); - } - - let is_dynamic = has_query; - - // Store the config table in owlry.provider._registrations[name] - // so call_refresh/call_query can find the callback functions later - let globals = lua.globals(); - let owlry: Table = globals.get("owlry")?; - let provider: Table = owlry.get("provider")?; - let registrations: Table = match provider.get::("_registrations")? { - Value::Table(t) => t, - _ => { - let t = lua.create_table()?; - provider.set("_registrations", t.clone())?; - t - } - }; - registrations.set(name.as_str(), config)?; - - REGISTRATIONS.with(|regs| { - regs.borrow_mut().push(ProviderRegistration { - name, - display_name, - type_id, - default_icon, - prefix, - is_dynamic, - tab_label, - search_noun, - }); - }); - - Ok(()) -} - -/// Call the top-level `refresh()` global function (for manifest-declared providers) -pub fn call_global_refresh(lua: &Lua) -> LuaResult> { - let globals = lua.globals(); - match globals.get::("refresh") { - Ok(refresh_fn) => parse_items_result(refresh_fn.call(())?), - Err(_) => Ok(Vec::new()), - } -} - -/// Get all registered providers -pub fn get_registrations(lua: &Lua) -> LuaResult> { - // Suppress unused warning - let _ = lua; - - REGISTRATIONS.with(|regs| Ok(regs.borrow().clone())) -} - -/// Call a provider's refresh function -pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult> { - let globals = lua.globals(); - let owlry: Table = globals.get("owlry")?; - let provider: Table = owlry.get("provider")?; - - // Get the registered providers table (internal) - let registrations: Table = match provider.get::("_registrations")? { - Value::Table(t) => t, - _ => { - // Try to find the config directly from the global scope - // This happens when register was called with the config table - return call_provider_function(lua, provider_name, "refresh", None); - } - }; - - let config: Table = match registrations.get(provider_name)? { - Value::Table(t) => t, - _ => return Ok(Vec::new()), - }; - - let refresh_fn: Function = match config.get("refresh")? { - Value::Function(f) => f, - _ => return Ok(Vec::new()), - }; - - let result: Value = refresh_fn.call(())?; - parse_items_result(result) -} - -/// Call a provider's query function -pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult> { - call_provider_function(lua, provider_name, "query", Some(query)) -} - -/// Call a provider function by name -fn call_provider_function( - lua: &Lua, - provider_name: &str, - function_name: &str, - query: Option<&str>, -) -> LuaResult> { - // Search through all registered providers in the Lua globals - // This is a workaround since we store registrations thread-locally - let globals = lua.globals(); - - // Try to find a registered provider with matching name - // First check if there's a _providers table - if let Ok(Value::Table(providers)) = globals.get::("_owlry_providers") - && let Ok(Value::Table(config)) = providers.get::(provider_name) - && let Ok(Value::Function(func)) = config.get::(function_name) - { - let result: Value = match query { - Some(q) => func.call(q)?, - None => func.call(())?, - }; - return parse_items_result(result); - } - - // Fall back: search through globals for functions - // This is less reliable but handles simple cases - Ok(Vec::new()) -} - -/// Parse items from Lua return value -fn parse_items_result(result: Value) -> LuaResult> { - let mut items = Vec::new(); - - if let Value::Table(table) = result { - for pair in table.pairs::() { - let (_, item_table) = pair?; - if let Ok(item) = parse_item(&item_table) { - items.push(item); - } - } - } - - Ok(items) -} - -/// Parse a single item from a Lua table -fn parse_item(table: &Table) -> LuaResult { - let id: String = table.get("id")?; - let name: String = table.get("name")?; - let command: String = table.get::>("command")?.unwrap_or_default(); - let description: Option = table.get("description")?; - let icon: Option = table.get("icon")?; - let terminal: bool = table.get::>("terminal")?.unwrap_or(false); - let tags: Vec = table - .get::>>("tags")? - .unwrap_or_default(); - - let mut item = PluginItem::new(id, name, command); - - if let Some(desc) = description { - item = item.with_description(desc); - } - if let Some(ic) = icon { - item = item.with_icon(&ic); - } - if terminal { - item = item.with_terminal(true); - } - if !tags.is_empty() { - item = item.with_keywords(tags); - } - - Ok(item) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::runtime::{SandboxConfig, create_lua_runtime}; - - #[test] - fn test_register_static_provider() { - let config = SandboxConfig::default(); - let lua = create_lua_runtime(&config).unwrap(); - - let owlry = lua.create_table().unwrap(); - register_provider_api(&lua, &owlry).unwrap(); - lua.globals().set("owlry", owlry).unwrap(); - - let code = r#" - owlry.provider.register({ - name = "test-provider", - display_name = "Test Provider", - refresh = function() - return { - { id = "1", name = "Item 1" } - } - end - }) - "#; - lua.load(code).set_name("test").call::<()>(()).unwrap(); - - let regs = get_registrations(&lua).unwrap(); - assert_eq!(regs.len(), 1); - assert_eq!(regs[0].name, "test-provider"); - assert!(!regs[0].is_dynamic); - } - - #[test] - fn test_register_dynamic_provider() { - let config = SandboxConfig::default(); - let lua = create_lua_runtime(&config).unwrap(); - - let owlry = lua.create_table().unwrap(); - register_provider_api(&lua, &owlry).unwrap(); - lua.globals().set("owlry", owlry).unwrap(); - - let code = r#" - owlry.provider.register({ - name = "query-provider", - prefix = "?", - query = function(q) - return { - { id = "search", name = "Search: " .. q } - } - end - }) - "#; - lua.load(code).set_name("test").call::<()>(()).unwrap(); - - let regs = get_registrations(&lua).unwrap(); - assert_eq!(regs.len(), 1); - assert_eq!(regs[0].name, "query-provider"); - assert!(regs[0].is_dynamic); - assert_eq!(regs[0].prefix, Some("?".to_string())); - } -} diff --git a/crates/owlry-lua/src/api/utils.rs b/crates/owlry-lua/src/api/utils.rs deleted file mode 100644 index 9fcac79..0000000 --- a/crates/owlry-lua/src/api/utils.rs +++ /dev/null @@ -1,447 +0,0 @@ -//! Utility APIs: logging, paths, filesystem, JSON - -use mlua::{Lua, Result as LuaResult, Table, Value}; -use std::path::{Path, PathBuf}; - -// ============================================================================ -// Logging API -// ============================================================================ - -/// Register the log API in the owlry table -pub fn register_log_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { - let log = lua.create_table()?; - - log.set( - "debug", - lua.create_function(|_, msg: String| { - eprintln!("[DEBUG] {}", msg); - Ok(()) - })?, - )?; - - log.set( - "info", - lua.create_function(|_, msg: String| { - eprintln!("[INFO] {}", msg); - Ok(()) - })?, - )?; - - log.set( - "warn", - lua.create_function(|_, msg: String| { - eprintln!("[WARN] {}", msg); - Ok(()) - })?, - )?; - - log.set( - "error", - lua.create_function(|_, msg: String| { - eprintln!("[ERROR] {}", msg); - Ok(()) - })?, - )?; - - owlry.set("log", log)?; - Ok(()) -} - -// ============================================================================ -// Path API -// ============================================================================ - -/// Register the path API in the owlry table -pub fn register_path_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> { - let path = lua.create_table()?; - - // owlry.path.config() -> ~/.config/owlry - path.set( - "config", - lua.create_function(|_, ()| { - Ok(dirs::config_dir() - .map(|d| d.join("owlry")) - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default()) - })?, - )?; - - // owlry.path.data() -> ~/.local/share/owlry - path.set( - "data", - lua.create_function(|_, ()| { - Ok(dirs::data_dir() - .map(|d| d.join("owlry")) - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default()) - })?, - )?; - - // owlry.path.cache() -> ~/.cache/owlry - path.set( - "cache", - lua.create_function(|_, ()| { - Ok(dirs::cache_dir() - .map(|d| d.join("owlry")) - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default()) - })?, - )?; - - // owlry.path.home() -> ~ - path.set( - "home", - lua.create_function(|_, ()| { - Ok(dirs::home_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default()) - })?, - )?; - - // owlry.path.join(...) -> joined path - path.set( - "join", - lua.create_function(|_, parts: mlua::Variadic| { - let mut path = PathBuf::new(); - for part in parts { - path.push(part); - } - Ok(path.to_string_lossy().to_string()) - })?, - )?; - - // owlry.path.plugin_dir() -> plugin directory - let plugin_dir_str = plugin_dir.to_string_lossy().to_string(); - path.set( - "plugin_dir", - lua.create_function(move |_, ()| Ok(plugin_dir_str.clone()))?, - )?; - - // owlry.path.expand(path) -> expanded path (~ -> home) - path.set( - "expand", - lua.create_function(|_, path: String| { - if path.starts_with("~/") - && let Some(home) = dirs::home_dir() - { - return Ok(home.join(&path[2..]).to_string_lossy().to_string()); - } - Ok(path) - })?, - )?; - - owlry.set("path", path)?; - Ok(()) -} - -// ============================================================================ -// Filesystem API -// ============================================================================ - -/// Register the fs API in the owlry table -pub fn register_fs_api(lua: &Lua, owlry: &Table, _plugin_dir: &Path) -> LuaResult<()> { - let fs = lua.create_table()?; - - // owlry.fs.exists(path) -> bool - fs.set( - "exists", - lua.create_function(|_, path: String| { - let path = expand_path(&path); - Ok(Path::new(&path).exists()) - })?, - )?; - - // owlry.fs.is_dir(path) -> bool - fs.set( - "is_dir", - lua.create_function(|_, path: String| { - let path = expand_path(&path); - Ok(Path::new(&path).is_dir()) - })?, - )?; - - // owlry.fs.read(path) -> string or nil - fs.set( - "read", - lua.create_function(|_, path: String| { - let path = expand_path(&path); - match std::fs::read_to_string(&path) { - Ok(content) => Ok(Some(content)), - Err(_) => Ok(None), - } - })?, - )?; - - // owlry.fs.read_lines(path) -> table of strings or nil - fs.set( - "read_lines", - lua.create_function(|lua, path: String| { - let path = expand_path(&path); - match std::fs::read_to_string(&path) { - Ok(content) => { - let lines: Vec = content.lines().map(|s| s.to_string()).collect(); - Ok(Some(lua.create_sequence_from(lines)?)) - } - Err(_) => Ok(None), - } - })?, - )?; - - // owlry.fs.list_dir(path) -> table of filenames or nil - fs.set( - "list_dir", - lua.create_function(|lua, path: String| { - let path = expand_path(&path); - match std::fs::read_dir(&path) { - Ok(entries) => { - let names: Vec = entries - .filter_map(|e| e.ok()) - .filter_map(|e| e.file_name().into_string().ok()) - .collect(); - Ok(Some(lua.create_sequence_from(names)?)) - } - Err(_) => Ok(None), - } - })?, - )?; - - // owlry.fs.read_json(path) -> table or nil - fs.set( - "read_json", - lua.create_function(|lua, path: String| { - let path = expand_path(&path); - match std::fs::read_to_string(&path) { - Ok(content) => match serde_json::from_str::(&content) { - Ok(value) => json_to_lua(lua, &value), - Err(_) => Ok(Value::Nil), - }, - Err(_) => Ok(Value::Nil), - } - })?, - )?; - - // owlry.fs.write(path, content) -> bool - fs.set( - "write", - lua.create_function(|_, (path, content): (String, String)| { - let path = expand_path(&path); - // Create parent directories if needed - if let Some(parent) = Path::new(&path).parent() { - let _ = std::fs::create_dir_all(parent); - } - Ok(std::fs::write(&path, content).is_ok()) - })?, - )?; - - owlry.set("fs", fs)?; - Ok(()) -} - -// ============================================================================ -// JSON API -// ============================================================================ - -/// Register the json API in the owlry table -pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { - let json = lua.create_table()?; - - // owlry.json.encode(value) -> string - json.set( - "encode", - lua.create_function(|lua, value: Value| { - let json_value = lua_to_json(lua, &value)?; - Ok(serde_json::to_string(&json_value).unwrap_or_else(|_| "null".to_string())) - })?, - )?; - - // owlry.json.decode(string) -> value or nil - json.set( - "decode", - lua.create_function(|lua, s: String| { - match serde_json::from_str::(&s) { - Ok(value) => json_to_lua(lua, &value), - Err(_) => Ok(Value::Nil), - } - })?, - )?; - - owlry.set("json", json)?; - Ok(()) -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -/// Expand ~ in paths -fn expand_path(path: &str) -> String { - if path.starts_with("~/") - && let Some(home) = dirs::home_dir() - { - return home.join(&path[2..]).to_string_lossy().to_string(); - } - path.to_string() -} - -/// Convert JSON value to Lua value -fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult { - match value { - serde_json::Value::Null => Ok(Value::Nil), - serde_json::Value::Bool(b) => Ok(Value::Boolean(*b)), - serde_json::Value::Number(n) => { - if let Some(i) = n.as_i64() { - Ok(Value::Integer(i)) - } else if let Some(f) = n.as_f64() { - Ok(Value::Number(f)) - } else { - Ok(Value::Nil) - } - } - serde_json::Value::String(s) => Ok(Value::String(lua.create_string(s)?)), - serde_json::Value::Array(arr) => { - let table = lua.create_table()?; - for (i, v) in arr.iter().enumerate() { - table.set(i + 1, json_to_lua(lua, v)?)?; - } - Ok(Value::Table(table)) - } - serde_json::Value::Object(obj) => { - let table = lua.create_table()?; - for (k, v) in obj { - table.set(k.as_str(), json_to_lua(lua, v)?)?; - } - Ok(Value::Table(table)) - } - } -} - -/// Convert Lua value to JSON value -fn lua_to_json(_lua: &Lua, value: &Value) -> LuaResult { - match value { - Value::Nil => Ok(serde_json::Value::Null), - Value::Boolean(b) => Ok(serde_json::Value::Bool(*b)), - Value::Integer(i) => Ok(serde_json::Value::Number((*i).into())), - Value::Number(n) => Ok(serde_json::json!(*n)), - Value::String(s) => Ok(serde_json::Value::String(s.to_str()?.to_string())), - Value::Table(t) => { - // Check if it's an array (sequential integer keys starting from 1) - let mut is_array = true; - let mut max_key = 0i64; - for pair in t.clone().pairs::() { - let (k, _) = pair?; - match k { - Value::Integer(i) if i > 0 => { - max_key = max_key.max(i); - } - _ => { - is_array = false; - break; - } - } - } - - if is_array && max_key > 0 { - let mut arr = Vec::new(); - for i in 1..=max_key { - let v: Value = t.get(i)?; - arr.push(lua_to_json(_lua, &v)?); - } - Ok(serde_json::Value::Array(arr)) - } else { - let mut obj = serde_json::Map::new(); - for pair in t.clone().pairs::() { - let (k, v) = pair?; - obj.insert(k, lua_to_json(_lua, &v)?); - } - Ok(serde_json::Value::Object(obj)) - } - } - _ => Ok(serde_json::Value::Null), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::runtime::{SandboxConfig, create_lua_runtime}; - - #[test] - fn test_log_api() { - let config = SandboxConfig::default(); - let lua = create_lua_runtime(&config).unwrap(); - let owlry = lua.create_table().unwrap(); - register_log_api(&lua, &owlry).unwrap(); - lua.globals().set("owlry", owlry).unwrap(); - - // Just verify it doesn't panic - lua.load("owlry.log.info('test message')") - .set_name("test") - .call::<()>(()) - .unwrap(); - } - - #[test] - fn test_path_api() { - let config = SandboxConfig::default(); - let lua = create_lua_runtime(&config).unwrap(); - let owlry = lua.create_table().unwrap(); - register_path_api(&lua, &owlry, Path::new("/tmp/test-plugin")).unwrap(); - lua.globals().set("owlry", owlry).unwrap(); - - let home: String = lua - .load("return owlry.path.home()") - .set_name("test") - .call(()) - .unwrap(); - assert!(!home.is_empty()); - - let plugin_dir: String = lua - .load("return owlry.path.plugin_dir()") - .set_name("test") - .call(()) - .unwrap(); - assert_eq!(plugin_dir, "/tmp/test-plugin"); - } - - #[test] - fn test_fs_api() { - let config = SandboxConfig::default(); - let lua = create_lua_runtime(&config).unwrap(); - let owlry = lua.create_table().unwrap(); - register_fs_api(&lua, &owlry, Path::new("/tmp")).unwrap(); - lua.globals().set("owlry", owlry).unwrap(); - - let exists: bool = lua - .load("return owlry.fs.exists('/tmp')") - .set_name("test") - .call(()) - .unwrap(); - assert!(exists); - - let is_dir: bool = lua - .load("return owlry.fs.is_dir('/tmp')") - .set_name("test") - .call(()) - .unwrap(); - assert!(is_dir); - } - - #[test] - fn test_json_api() { - let config = SandboxConfig::default(); - let lua = create_lua_runtime(&config).unwrap(); - let owlry = lua.create_table().unwrap(); - register_json_api(&lua, &owlry).unwrap(); - lua.globals().set("owlry", owlry).unwrap(); - - let code = r#" - local t = { name = "test", value = 42 } - local json = owlry.json.encode(t) - local decoded = owlry.json.decode(json) - return decoded.name, decoded.value - "#; - let (name, value): (String, i32) = lua.load(code).set_name("test").call(()).unwrap(); - assert_eq!(name, "test"); - assert_eq!(value, 42); - } -} diff --git a/crates/owlry-lua/src/lib.rs b/crates/owlry-lua/src/lib.rs deleted file mode 100644 index e3da514..0000000 --- a/crates/owlry-lua/src/lib.rs +++ /dev/null @@ -1,347 +0,0 @@ -//! Owlry Lua Runtime -//! -//! This crate provides Lua plugin support for owlry. It is loaded dynamically -//! by the core when Lua plugins need to be executed. -//! -//! # Architecture -//! -//! The runtime acts as a "meta-plugin" that: -//! 1. Discovers Lua plugins in `~/.config/owlry/plugins/` -//! 2. Creates sandboxed Lua VMs for each plugin -//! 3. Registers the `owlry` API table -//! 4. Bridges Lua providers to native `PluginItem` format -//! -//! # Plugin Structure -//! -//! Each plugin lives in its own directory: -//! ```text -//! ~/.config/owlry/plugins/ -//! my-plugin/ -//! plugin.toml # Plugin manifest -//! init.lua # Entry point -//! ``` - -mod api; -mod loader; -mod manifest; -mod runtime; - -use abi_stable::std_types::{ROption, RStr, RString, RVec}; -use owlry_plugin_api::PluginItem; -use std::collections::HashMap; -use std::path::PathBuf; - -use loader::LoadedPlugin; - -/// Runtime vtable - exported interface for the core to use -#[repr(C)] -pub struct LuaRuntimeVTable { - /// Get runtime info - pub info: extern "C" fn() -> RuntimeInfo, - /// Initialize the runtime with plugins directory - pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle, - /// Get provider infos from all loaded plugins - pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec, - /// Refresh a provider's items - pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec, - /// Query a dynamic provider - pub query: extern "C" fn( - handle: RuntimeHandle, - provider_id: RStr<'_>, - query: RStr<'_>, - ) -> RVec, - /// Cleanup and drop the runtime - pub drop: extern "C" fn(handle: RuntimeHandle), -} - -/// Runtime info returned by the runtime -#[repr(C)] -pub struct RuntimeInfo { - pub name: RString, - pub version: RString, -} - -/// Opaque handle to the runtime state -#[repr(C)] -#[derive(Clone, Copy)] -pub struct RuntimeHandle { - pub ptr: *mut (), -} - -// SAFETY: LuaRuntimeState (pointed to by RuntimeHandle) contains mlua::Lua, which is -// Send when the "send" feature is enabled (enabled in Cargo.toml). RuntimeHandle itself -// is Copy and has no interior mutability — Sync is NOT implemented because concurrent -// access is serialized by Arc> in the runtime loader. -unsafe impl Send for RuntimeHandle {} - -impl RuntimeHandle { - /// Create a null handle (reserved for error cases) - #[allow(dead_code)] - fn null() -> Self { - Self { - ptr: std::ptr::null_mut(), - } - } - - fn from_box(state: Box) -> Self { - Self { - ptr: Box::into_raw(state) as *mut (), - } - } - - unsafe fn drop_as(&self) { - if !self.ptr.is_null() { - unsafe { drop(Box::from_raw(self.ptr as *mut T)) }; - } - } -} - -/// Provider info from a Lua plugin -/// -/// Must match ScriptProviderInfo layout in owlry-core/src/plugins/runtime_loader.rs -#[repr(C)] -pub struct LuaProviderInfo { - /// Provider name (used as vtable refresh/query key: "plugin_id:provider_name") - pub name: RString, - /// Display name - pub display_name: RString, - /// Type ID for filtering - pub type_id: RString, - /// Icon name - pub default_icon: RString, - /// Whether this is a static provider (true) or dynamic (false) - pub is_static: bool, - /// Optional prefix trigger - pub prefix: ROption, - /// Short label for UI tab button - pub tab_label: ROption, - /// Noun for search placeholder - pub search_noun: ROption, -} - -/// Internal runtime state -struct LuaRuntimeState { - plugins_dir: PathBuf, - plugins: HashMap, - /// Maps "plugin_id:provider_name" to plugin_id for lookup - provider_map: HashMap, -} - -impl LuaRuntimeState { - fn new(plugins_dir: PathBuf) -> Self { - Self { - plugins_dir, - plugins: HashMap::new(), - provider_map: HashMap::new(), - } - } - - fn discover_and_load(&mut self, owlry_version: &str) { - let discovered = match loader::discover_plugins(&self.plugins_dir) { - Ok(d) => d, - Err(e) => { - eprintln!("owlry-lua: Failed to discover plugins: {}", e); - return; - } - }; - - for (id, (manifest, path)) in discovered { - // Check version compatibility - if !manifest.is_compatible_with(owlry_version) { - eprintln!( - "owlry-lua: Plugin '{}' not compatible with owlry {}", - id, owlry_version - ); - continue; - } - - let mut plugin = LoadedPlugin::new(manifest, path); - if let Err(e) = plugin.initialize() { - eprintln!("owlry-lua: Failed to initialize plugin '{}': {}", id, e); - continue; - } - - // Build provider map - if let Ok(registrations) = plugin.get_provider_registrations() { - for reg in ®istrations { - let full_id = format!("{}:{}", id, reg.name); - self.provider_map.insert(full_id, id.clone()); - } - } - - self.plugins.insert(id, plugin); - } - } - - fn get_providers(&self) -> Vec { - let mut providers = Vec::new(); - - for (plugin_id, plugin) in &self.plugins { - if let Ok(registrations) = plugin.get_provider_registrations() { - for reg in registrations { - let full_id = format!("{}:{}", plugin_id, reg.name); - - providers.push(LuaProviderInfo { - name: RString::from(full_id), - display_name: RString::from(reg.display_name.as_str()), - type_id: RString::from(reg.type_id.as_str()), - default_icon: RString::from(reg.default_icon.as_str()), - is_static: !reg.is_dynamic, - prefix: reg.prefix.map(RString::from).into(), - tab_label: reg.tab_label.map(RString::from).into(), - search_noun: reg.search_noun.map(RString::from).into(), - }); - } - } - } - - providers - } - - fn refresh_provider(&self, provider_id: &str) -> Vec { - // Parse "plugin_id:provider_name" - let parts: Vec<&str> = provider_id.splitn(2, ':').collect(); - if parts.len() != 2 { - return Vec::new(); - } - let (plugin_id, provider_name) = (parts[0], parts[1]); - - if let Some(plugin) = self.plugins.get(plugin_id) { - match plugin.call_provider_refresh(provider_name) { - Ok(items) => items, - Err(e) => { - eprintln!("owlry-lua: Refresh failed for {}: {}", provider_id, e); - Vec::new() - } - } - } else { - Vec::new() - } - } - - fn query_provider(&self, provider_id: &str, query: &str) -> Vec { - // Parse "plugin_id:provider_name" - let parts: Vec<&str> = provider_id.splitn(2, ':').collect(); - if parts.len() != 2 { - return Vec::new(); - } - let (plugin_id, provider_name) = (parts[0], parts[1]); - - if let Some(plugin) = self.plugins.get(plugin_id) { - match plugin.call_provider_query(provider_name, query) { - Ok(items) => items, - Err(e) => { - eprintln!("owlry-lua: Query failed for {}: {}", provider_id, e); - Vec::new() - } - } - } else { - Vec::new() - } - } -} - -// ============================================================================ -// Exported Functions -// ============================================================================ - -extern "C" fn runtime_info() -> RuntimeInfo { - RuntimeInfo { - name: RString::from("Lua"), - version: RString::from(env!("CARGO_PKG_VERSION")), - } -} - -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) -} - -extern "C" fn runtime_providers(handle: RuntimeHandle) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - let state = unsafe { &*(handle.ptr as *const LuaRuntimeState) }; - state.get_providers().into() -} - -extern "C" fn runtime_refresh(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - let state = unsafe { &*(handle.ptr as *const LuaRuntimeState) }; - state.refresh_provider(provider_id.as_str()).into() -} - -extern "C" fn runtime_query( - handle: RuntimeHandle, - provider_id: RStr<'_>, - query: RStr<'_>, -) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - let state = unsafe { &*(handle.ptr as *const LuaRuntimeState) }; - state - .query_provider(provider_id.as_str(), query.as_str()) - .into() -} - -extern "C" fn runtime_drop(handle: RuntimeHandle) { - if !handle.ptr.is_null() { - unsafe { - handle.drop_as::(); - } - } -} - -/// Static vtable instance -static LUA_RUNTIME_VTABLE: LuaRuntimeVTable = LuaRuntimeVTable { - info: runtime_info, - init: runtime_init, - providers: runtime_providers, - refresh: runtime_refresh, - query: runtime_query, - drop: runtime_drop, -}; - -/// Entry point - returns the runtime vtable -#[unsafe(no_mangle)] -pub extern "C" fn owlry_lua_runtime_vtable() -> &'static LuaRuntimeVTable { - &LUA_RUNTIME_VTABLE -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_runtime_info() { - let info = runtime_info(); - assert_eq!(info.name.as_str(), "Lua"); - assert!(!info.version.as_str().is_empty()); - } - - #[test] - fn test_runtime_handle_null() { - let handle = RuntimeHandle::null(); - assert!(handle.ptr.is_null()); - } - - #[test] - fn test_runtime_handle_from_box() { - let state = Box::new(42u32); - let handle = RuntimeHandle::from_box(state); - assert!(!handle.ptr.is_null()); - unsafe { handle.drop_as::() }; - } -} diff --git a/crates/owlry-lua/src/loader.rs b/crates/owlry-lua/src/loader.rs deleted file mode 100644 index fc17ad2..0000000 --- a/crates/owlry-lua/src/loader.rs +++ /dev/null @@ -1,350 +0,0 @@ -//! Plugin discovery and loading - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use mlua::Lua; -use owlry_plugin_api::PluginItem; - -use crate::api; -use crate::manifest::PluginManifest; -use crate::runtime::{SandboxConfig, create_lua_runtime, load_file}; - -/// Provider registration info from Lua -#[derive(Debug, Clone)] -pub struct ProviderRegistration { - pub name: String, - pub display_name: String, - pub type_id: String, - pub default_icon: String, - pub prefix: Option, - pub is_dynamic: bool, - pub tab_label: Option, - pub search_noun: Option, -} - -/// A loaded plugin instance -pub struct LoadedPlugin { - /// Plugin manifest - pub manifest: PluginManifest, - /// Path to plugin directory - pub path: PathBuf, - /// Whether plugin is enabled - pub enabled: bool, - /// Lua runtime (None if not yet initialized) - lua: Option, -} - -impl std::fmt::Debug for LoadedPlugin { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("LoadedPlugin") - .field("manifest", &self.manifest) - .field("path", &self.path) - .field("enabled", &self.enabled) - .field("lua", &self.lua.is_some()) - .finish() - } -} - -impl LoadedPlugin { - /// Create a new loaded plugin (not yet initialized) - pub fn new(manifest: PluginManifest, path: PathBuf) -> Self { - Self { - manifest, - path, - enabled: true, - lua: None, - } - } - - /// Get the plugin ID - pub fn id(&self) -> &str { - &self.manifest.plugin.id - } - - /// Initialize the Lua runtime and load the entry point - pub fn initialize(&mut self) -> Result<(), String> { - if self.lua.is_some() { - return Ok(()); // Already initialized - } - - let sandbox = SandboxConfig::from_permissions(&self.manifest.permissions); - let lua = create_lua_runtime(&sandbox) - .map_err(|e| format!("Failed to create Lua runtime: {}", e))?; - - // Register owlry APIs before loading entry point - api::register_apis(&lua, &self.path, self.id()) - .map_err(|e| format!("Failed to register APIs: {}", e))?; - - // Load the entry point file - let entry_path = self.path.join(&self.manifest.plugin.entry); - if !entry_path.exists() { - return Err(format!( - "Entry point '{}' not found", - self.manifest.plugin.entry - )); - } - - load_file(&lua, &entry_path).map_err(|e| format!("Failed to load entry point: {}", e))?; - - self.lua = Some(lua); - Ok(()) - } - - /// Get provider registrations from this plugin - pub fn get_provider_registrations(&self) -> Result, String> { - let lua = self - .lua - .as_ref() - .ok_or_else(|| "Plugin not initialized".to_string())?; - - let mut regs = api::get_provider_registrations(lua) - .map_err(|e| format!("Failed to get registrations: {}", e))?; - - // Fall back to manifest [[providers]] declarations when the script - // doesn't call owlry.provider.register() (new-style plugins) - if regs.is_empty() { - for decl in &self.manifest.providers { - regs.push(ProviderRegistration { - name: decl.id.clone(), - display_name: decl.name.clone(), - type_id: decl.type_id.clone().unwrap_or_else(|| decl.id.clone()), - default_icon: decl - .icon - .clone() - .unwrap_or_else(|| "application-x-addon".to_string()), - prefix: decl.prefix.clone(), - is_dynamic: decl.provider_type == "dynamic", - tab_label: decl.tab_label.clone(), - search_noun: decl.search_noun.clone(), - }); - } - } - - Ok(regs) - } - - /// Call a provider's refresh function - pub fn call_provider_refresh(&self, provider_name: &str) -> Result, String> { - let lua = self - .lua - .as_ref() - .ok_or_else(|| "Plugin not initialized".to_string())?; - - let items = api::call_refresh(lua, provider_name) - .map_err(|e| format!("Refresh failed: {}", e))?; - - // If the API path returned nothing, try calling the global refresh() - // function directly (new-style plugins with manifest [[providers]]) - if items.is_empty() { - return api::call_global_refresh(lua) - .map_err(|e| format!("Refresh failed: {}", e)); - } - - Ok(items) - } - - /// Call a provider's query function - pub fn call_provider_query( - &self, - provider_name: &str, - query: &str, - ) -> Result, String> { - let lua = self - .lua - .as_ref() - .ok_or_else(|| "Plugin not initialized".to_string())?; - - api::call_query(lua, provider_name, query).map_err(|e| format!("Query failed: {}", e)) - } -} - -/// Discover plugins in a directory -pub fn discover_plugins( - plugins_dir: &Path, -) -> Result, String> { - let mut plugins = HashMap::new(); - - if !plugins_dir.exists() { - return Ok(plugins); - } - - let entries = std::fs::read_dir(plugins_dir) - .map_err(|e| format!("Failed to read plugins directory: {}", e))?; - - for entry in entries { - let entry = match entry { - Ok(e) => e, - Err(_) => continue, - }; - let path = entry.path(); - - if !path.is_dir() { - continue; - } - - let manifest_path = path.join("plugin.toml"); - if !manifest_path.exists() { - continue; - } - - match PluginManifest::load(&manifest_path) { - Ok(manifest) => { - // Skip plugins whose entry point is not a Lua file - if !manifest.plugin.entry.ends_with(".lua") { - log::debug!( - "owlry-lua: Skipping non-Lua plugin at {} (entry: {})", - path.display(), - manifest.plugin.entry - ); - continue; - } - let id = manifest.plugin.id.clone(); - if plugins.contains_key(&id) { - log::warn!( - "owlry-lua: Duplicate plugin ID '{}', skipping {}", - id, - path.display() - ); - continue; - } - plugins.insert(id, (manifest, path)); - } - Err(e) => { - log::warn!( - "owlry-lua: Failed to load plugin at {}: {}", - path.display(), - e - ); - } - } - } - - Ok(plugins) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - fn create_test_plugin(dir: &Path, id: &str) { - let plugin_dir = dir.join(id); - fs::create_dir_all(&plugin_dir).unwrap(); - - let manifest = format!( - r#" -[plugin] -id = "{}" -name = "Test {}" -version = "1.0.0" -"#, - id, id - ); - fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); - fs::write(plugin_dir.join("main.lua"), "-- empty plugin").unwrap(); - } - - #[test] - fn test_discover_plugins() { - let temp = TempDir::new().unwrap(); - let plugins_dir = temp.path(); - - create_test_plugin(plugins_dir, "test-plugin"); - create_test_plugin(plugins_dir, "another-plugin"); - - let plugins = discover_plugins(plugins_dir).unwrap(); - assert_eq!(plugins.len(), 2); - assert!(plugins.contains_key("test-plugin")); - assert!(plugins.contains_key("another-plugin")); - } - - #[test] - fn test_discover_plugins_empty_dir() { - let temp = TempDir::new().unwrap(); - let plugins = discover_plugins(temp.path()).unwrap(); - assert!(plugins.is_empty()); - } - - #[test] - fn test_discover_plugins_nonexistent_dir() { - let plugins = discover_plugins(Path::new("/nonexistent/path")).unwrap(); - assert!(plugins.is_empty()); - } - - #[test] - fn test_discover_skips_non_lua_plugins() { - let temp = TempDir::new().unwrap(); - let plugins_dir = temp.path(); - - // Rune plugin — should be skipped by the Lua runtime - let rune_dir = plugins_dir.join("rune-plugin"); - fs::create_dir_all(&rune_dir).unwrap(); - fs::write( - rune_dir.join("plugin.toml"), - r#" -[plugin] -id = "rune-plugin" -name = "Rune Plugin" -version = "1.0.0" -entry_point = "main.rn" - -[[providers]] -id = "rune-plugin" -name = "Rune Plugin" -"#, - ) - .unwrap(); - fs::write(rune_dir.join("main.rn"), "pub fn refresh() { [] }").unwrap(); - - // Lua plugin — should be discovered - create_test_plugin(plugins_dir, "lua-plugin"); - - let plugins = discover_plugins(plugins_dir).unwrap(); - assert_eq!(plugins.len(), 1); - assert!(plugins.contains_key("lua-plugin")); - assert!(!plugins.contains_key("rune-plugin")); - } - - #[test] - fn test_manifest_provider_fallback() { - let temp = TempDir::new().unwrap(); - let plugin_dir = temp.path().join("test-plugin"); - fs::create_dir_all(&plugin_dir).unwrap(); - - fs::write( - plugin_dir.join("plugin.toml"), - r#" -[plugin] -id = "test-plugin" -name = "Test Plugin" -version = "1.0.0" -entry_point = "main.lua" - -[[providers]] -id = "test-plugin" -name = "Test Plugin" -type = "static" -type_id = "testplugin" -icon = "system-run" -prefix = ":tp" -"#, - ) - .unwrap(); - // Script that does NOT call owlry.provider.register() - fs::write(plugin_dir.join("main.lua"), "function refresh() return {} end").unwrap(); - - let manifest = - crate::manifest::PluginManifest::load(&plugin_dir.join("plugin.toml")).unwrap(); - let mut plugin = LoadedPlugin::new(manifest, plugin_dir); - plugin.initialize().unwrap(); - - let regs = plugin.get_provider_registrations().unwrap(); - assert_eq!(regs.len(), 1, "should fall back to [[providers]] declaration"); - assert_eq!(regs[0].name, "test-plugin"); - assert_eq!(regs[0].type_id, "testplugin"); - assert_eq!(regs[0].prefix.as_deref(), Some(":tp")); - assert!(!regs[0].is_dynamic); - } -} diff --git a/crates/owlry-lua/src/manifest.rs b/crates/owlry-lua/src/manifest.rs deleted file mode 100644 index 19c9b93..0000000 --- a/crates/owlry-lua/src/manifest.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! Plugin manifest (plugin.toml) parsing - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::Path; - -/// Plugin manifest loaded from plugin.toml -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginManifest { - pub plugin: PluginInfo, - /// Provider declarations from [[providers]] sections (new-style) - #[serde(default)] - pub providers: Vec, - /// Legacy provides block (old-style) - #[serde(default)] - pub provides: PluginProvides, - #[serde(default)] - pub permissions: PluginPermissions, - #[serde(default)] - pub settings: HashMap, -} - -/// A provider declared in a [[providers]] section -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProviderDecl { - pub id: String, - pub name: String, - #[serde(default)] - pub prefix: Option, - #[serde(default)] - pub icon: Option, - /// "static" (default) or "dynamic" - #[serde(default = "default_provider_type", rename = "type")] - pub provider_type: String, - #[serde(default)] - pub type_id: Option, - #[serde(default)] - pub tab_label: Option, - #[serde(default)] - pub search_noun: Option, -} - -fn default_provider_type() -> String { - "static".to_string() -} - -/// Core plugin information -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginInfo { - /// Unique plugin identifier (lowercase, alphanumeric, hyphens) - pub id: String, - /// Human-readable name - pub name: String, - /// Semantic version - pub version: String, - /// Short description - #[serde(default)] - pub description: String, - /// Plugin author - #[serde(default)] - pub author: String, - /// License identifier - #[serde(default)] - pub license: String, - /// Repository URL - #[serde(default)] - pub repository: Option, - /// Required owlry version (semver constraint) - #[serde(default = "default_owlry_version")] - pub owlry_version: String, - /// Entry point file (relative to plugin directory) - #[serde(default = "default_entry", alias = "entry_point")] - pub entry: String, -} - -fn default_owlry_version() -> String { - ">=0.1.0".to_string() -} - -fn default_entry() -> String { - "main.lua".to_string() -} - -/// What the plugin provides -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct PluginProvides { - /// Provider names this plugin registers - #[serde(default)] - pub providers: Vec, - /// Whether this plugin registers actions - #[serde(default)] - pub actions: bool, - /// Theme names this plugin contributes - #[serde(default)] - pub themes: Vec, - /// Whether this plugin registers hooks - #[serde(default)] - pub hooks: bool, -} - -/// Plugin permissions/capabilities -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct PluginPermissions { - /// Allow network/HTTP requests - #[serde(default)] - pub network: bool, - /// Filesystem paths the plugin can access (beyond its own directory) - #[serde(default)] - pub filesystem: Vec, - /// Commands the plugin is allowed to run - #[serde(default)] - pub run_commands: Vec, - /// Environment variables the plugin reads - #[serde(default)] - pub environment: Vec, -} - -impl PluginManifest { - /// Load a plugin manifest from a plugin.toml file - pub fn load(path: &Path) -> Result { - let content = - std::fs::read_to_string(path).map_err(|e| format!("Failed to read manifest: {}", e))?; - let manifest: PluginManifest = - toml::from_str(&content).map_err(|e| format!("Failed to parse manifest: {}", e))?; - manifest.validate()?; - Ok(manifest) - } - - /// Validate the manifest - fn validate(&self) -> Result<(), String> { - // Validate plugin ID format - if self.plugin.id.is_empty() { - return Err("Plugin ID cannot be empty".to_string()); - } - - if !self - .plugin - .id - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - { - return Err("Plugin ID must be lowercase alphanumeric with hyphens".to_string()); - } - - // Validate version format - if semver::Version::parse(&self.plugin.version).is_err() { - return Err(format!("Invalid version format: {}", self.plugin.version)); - } - - // Validate owlry_version constraint - if semver::VersionReq::parse(&self.plugin.owlry_version).is_err() { - return Err(format!( - "Invalid owlry_version constraint: {}", - self.plugin.owlry_version - )); - } - - // Lua plugins must have a .lua entry point - if !self.plugin.entry.ends_with(".lua") { - return Err("Entry point must be a .lua file for Lua plugins".to_string()); - } - - Ok(()) - } - - /// Check if this plugin is compatible with the given owlry version - pub fn is_compatible_with(&self, owlry_version: &str) -> bool { - let req = match semver::VersionReq::parse(&self.plugin.owlry_version) { - Ok(r) => r, - Err(_) => return false, - }; - let version = match semver::Version::parse(owlry_version) { - Ok(v) => v, - Err(_) => return false, - }; - req.matches(&version) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_minimal_manifest() { - let toml_str = r#" -[plugin] -id = "test-plugin" -name = "Test Plugin" -version = "1.0.0" -"#; - let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); - assert_eq!(manifest.plugin.id, "test-plugin"); - assert_eq!(manifest.plugin.name, "Test Plugin"); - assert_eq!(manifest.plugin.version, "1.0.0"); - assert_eq!(manifest.plugin.entry, "main.lua"); - } - - #[test] - fn test_version_compatibility() { - let toml_str = r#" -[plugin] -id = "test" -name = "Test" -version = "1.0.0" -owlry_version = ">=0.3.0, <1.0.0" -"#; - let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); - assert!(manifest.is_compatible_with("0.3.5")); - assert!(manifest.is_compatible_with("0.4.0")); - assert!(!manifest.is_compatible_with("0.2.0")); - assert!(!manifest.is_compatible_with("1.0.0")); - } -} diff --git a/crates/owlry-lua/src/runtime.rs b/crates/owlry-lua/src/runtime.rs deleted file mode 100644 index dfea9b7..0000000 --- a/crates/owlry-lua/src/runtime.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Lua runtime setup and sandboxing - -use mlua::{Lua, Result as LuaResult, StdLib}; - -use crate::manifest::PluginPermissions; - -/// Configuration for the Lua sandbox -/// -/// Note: Some fields are reserved for future sandbox enforcement. -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct SandboxConfig { - /// Allow shell command running (reserved for future enforcement) - pub allow_commands: bool, - /// Allow HTTP requests (reserved for future enforcement) - pub allow_network: bool, - /// Allow filesystem access outside plugin directory (reserved for future enforcement) - pub allow_external_fs: bool, - /// Maximum run time per call (ms) (reserved for future enforcement) - pub max_run_time_ms: u64, - /// Memory limit (bytes, 0 = unlimited) (reserved for future enforcement) - pub max_memory: usize, -} - -impl Default for SandboxConfig { - fn default() -> Self { - Self { - allow_commands: false, - allow_network: false, - allow_external_fs: false, - max_run_time_ms: 5000, // 5 seconds - max_memory: 64 * 1024 * 1024, // 64 MB - } - } -} - -impl SandboxConfig { - /// Create a sandbox config from plugin permissions - pub fn from_permissions(permissions: &PluginPermissions) -> Self { - Self { - allow_commands: !permissions.run_commands.is_empty(), - allow_network: permissions.network, - allow_external_fs: !permissions.filesystem.is_empty(), - ..Default::default() - } - } -} - -/// Create a new sandboxed Lua runtime -pub fn create_lua_runtime(_sandbox: &SandboxConfig) -> LuaResult { - // Create Lua with safe standard libraries only - // We exclude: debug, io, os (dangerous parts), package (loadlib), ffi - let libs = StdLib::COROUTINE | StdLib::TABLE | StdLib::STRING | StdLib::UTF8 | StdLib::MATH; - - let lua = Lua::new_with(libs, mlua::LuaOptions::default())?; - - // Set up safe environment - setup_safe_globals(&lua)?; - - Ok(lua) -} - -/// Set up safe global environment by removing/replacing dangerous functions -fn setup_safe_globals(lua: &Lua) -> LuaResult<()> { - let globals = lua.globals(); - - // Remove dangerous globals - globals.set("dofile", mlua::Value::Nil)?; - globals.set("loadfile", mlua::Value::Nil)?; - - // Create a restricted os table with only safe functions - let os_table = lua.create_table()?; - os_table.set( - "clock", - lua.create_function(|_, ()| Ok(std::time::Instant::now().elapsed().as_secs_f64()))?, - )?; - os_table.set("date", lua.create_function(os_date)?)?; - os_table.set( - "difftime", - lua.create_function(|_, (t2, t1): (f64, f64)| Ok(t2 - t1))?, - )?; - os_table.set("time", lua.create_function(os_time)?)?; - globals.set("os", os_table)?; - - // Remove print (plugins should use owlry.log instead) - globals.set("print", mlua::Value::Nil)?; - - Ok(()) -} - -/// Safe os.date implementation -fn os_date(_lua: &Lua, format: Option) -> LuaResult { - use chrono::Local; - let now = Local::now(); - let fmt = format.unwrap_or_else(|| "%c".to_string()); - Ok(now.format(&fmt).to_string()) -} - -/// Safe os.time implementation -fn os_time(_lua: &Lua, _args: ()) -> LuaResult { - use std::time::{SystemTime, UNIX_EPOCH}; - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - Ok(duration.as_secs() as i64) -} - -/// Load and run a Lua file in the given runtime -pub fn load_file(lua: &Lua, path: &std::path::Path) -> LuaResult<()> { - let content = std::fs::read_to_string(path).map_err(mlua::Error::external)?; - lua.load(&content) - .set_name(path.file_name().and_then(|n| n.to_str()).unwrap_or("chunk")) - .into_function()? - .call(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_create_sandboxed_runtime() { - let config = SandboxConfig::default(); - let lua = create_lua_runtime(&config).unwrap(); - - // Verify dangerous functions are removed - let result: LuaResult = lua.globals().get("dofile"); - assert!(matches!(result, Ok(mlua::Value::Nil))); - - // Verify safe functions work - let result: String = lua.load("return os.date('%Y')").call(()).unwrap(); - assert!(!result.is_empty()); - } - - #[test] - fn test_basic_lua_operations() { - let config = SandboxConfig::default(); - let lua = create_lua_runtime(&config).unwrap(); - - // Test basic math - let result: i32 = lua.load("return 2 + 2").call(()).unwrap(); - assert_eq!(result, 4); - - // Test table operations - let result: i32 = lua.load("local t = {1,2,3}; return #t").call(()).unwrap(); - assert_eq!(result, 3); - - // Test string operations - let result: String = lua.load("return string.upper('hello')").call(()).unwrap(); - assert_eq!(result, "HELLO"); - } -} diff --git a/crates/owlry-plugin-api/Cargo.toml b/crates/owlry-plugin-api/Cargo.toml deleted file mode 100644 index b0c34c1..0000000 --- a/crates/owlry-plugin-api/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "owlry-plugin-api" -version = "1.0.1" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "Plugin API for owlry application launcher" -keywords = ["owlry", "plugin", "api"] -categories = ["api-bindings"] - -[dependencies] -# ABI-stable types for dynamic linking -abi_stable = "0.11" - -# Serialization for plugin config -serde = { version = "1", features = ["derive"] } diff --git a/crates/owlry-plugin-api/src/lib.rs b/crates/owlry-plugin-api/src/lib.rs deleted file mode 100644 index 48c201e..0000000 --- a/crates/owlry-plugin-api/src/lib.rs +++ /dev/null @@ -1,487 +0,0 @@ -//! # Owlry Plugin API -//! -//! This crate provides the ABI-stable interface for owlry native plugins. -//! Plugins are compiled as dynamic libraries (.so) and loaded at runtime. -//! -//! ## Creating a Plugin -//! -//! ```ignore -//! use owlry_plugin_api::*; -//! -//! // Define your plugin's vtable -//! static VTABLE: PluginVTable = PluginVTable { -//! info: plugin_info, -//! providers: plugin_providers, -//! provider_init: my_provider_init, -//! provider_refresh: my_provider_refresh, -//! provider_query: my_provider_query, -//! provider_drop: my_provider_drop, -//! }; -//! -//! // Export the vtable -//! #[no_mangle] -//! pub extern "C" fn owlry_plugin_vtable() -> &'static PluginVTable { -//! &VTABLE -//! } -//! ``` - -use abi_stable::StableAbi; - -// Re-export abi_stable types for use by consumers (runtime loader, plugins) -pub use abi_stable::std_types::{ROption, RStr, RString, RVec}; - -/// Current plugin API version - plugins must match this -/// v2: Added ProviderPosition for widget support -/// v3: Added priority field for plugin-declared result ordering -/// v4: Added get_config_string/int/bool to HostAPI for plugin config access -pub const API_VERSION: u32 = 4; - -/// Plugin metadata returned by the info function -#[repr(C)] -#[derive(StableAbi, Clone, Debug)] -pub struct PluginInfo { - /// Unique plugin identifier (e.g., "calculator", "weather") - pub id: RString, - /// Human-readable plugin name - pub name: RString, - /// Plugin version string - pub version: RString, - /// Short description of what the plugin provides - pub description: RString, - /// Plugin API version (must match API_VERSION) - pub api_version: u32, -} - -/// Information about a provider offered by a plugin -#[repr(C)] -#[derive(StableAbi, Clone, Debug)] -pub struct ProviderInfo { - /// Unique provider identifier within the plugin - pub id: RString, - /// Human-readable provider name - pub name: RString, - /// Optional prefix that activates this provider (e.g., "=" for calculator) - pub prefix: ROption, - /// Default icon name for results from this provider - pub icon: RString, - /// Provider type (static or dynamic) - pub provider_type: ProviderKind, - /// Short type identifier for UI badges (e.g., "calc", "web") - pub type_id: RString, - /// Display position (Normal or Widget) - pub position: ProviderPosition, - /// Priority for result ordering (higher values appear first) - /// Suggested ranges: - /// - Widgets: 10000-12000 - /// - Dynamic providers: 7000-10000 - /// - Static providers: 0-5000 (use 0 for frecency-based ordering) - pub priority: i32, -} - -/// Provider behavior type -#[repr(C)] -#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq)] -pub enum ProviderKind { - /// Static providers load items once at startup via refresh() - Static, - /// Dynamic providers evaluate queries in real-time via query() - Dynamic, -} - -/// Provider display position -/// -/// Controls where in the result list this provider's items appear. -#[repr(C)] -#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum ProviderPosition { - /// Standard position in results (sorted by score/frecency) - #[default] - Normal, - /// Widget position - appears at top of results when query is empty - /// Widgets are always visible regardless of filter settings - Widget, -} - -/// A single searchable/launchable item returned by providers -#[repr(C)] -#[derive(StableAbi, Clone, Debug)] -pub struct PluginItem { - /// Unique item identifier - pub id: RString, - /// Display name - pub name: RString, - /// Optional description shown below the name - pub description: ROption, - /// Optional icon name or path - pub icon: ROption, - /// Command to execute when selected - pub command: RString, - /// Whether to run in a terminal - pub terminal: bool, - /// Search keywords/tags for filtering - pub keywords: RVec, - /// Score boost for frecency (higher = more prominent) - pub score_boost: i32, -} - -impl PluginItem { - /// Create a new plugin item with required fields - pub fn new(id: impl Into, name: impl Into, command: impl Into) -> Self { - Self { - id: RString::from(id.into()), - name: RString::from(name.into()), - description: ROption::RNone, - icon: ROption::RNone, - command: RString::from(command.into()), - terminal: false, - keywords: RVec::new(), - score_boost: 0, - } - } - - /// Set the description - pub fn with_description(mut self, desc: impl Into) -> Self { - self.description = ROption::RSome(RString::from(desc.into())); - self - } - - /// Set the icon - pub fn with_icon(mut self, icon: impl Into) -> Self { - self.icon = ROption::RSome(RString::from(icon.into())); - self - } - - /// Set terminal mode - pub fn with_terminal(mut self, terminal: bool) -> Self { - self.terminal = terminal; - self - } - - /// Add keywords - pub fn with_keywords(mut self, keywords: Vec) -> Self { - self.keywords = keywords.into_iter().map(RString::from).collect(); - self - } - - /// Set score boost - pub fn with_score_boost(mut self, boost: i32) -> Self { - self.score_boost = boost; - self - } -} - -/// Plugin function table - defines the interface between owlry and plugins -/// -/// Every native plugin must export a function `owlry_plugin_vtable` that returns -/// a static reference to this structure. -#[repr(C)] -#[derive(StableAbi)] -pub struct PluginVTable { - /// Return plugin metadata - pub info: extern "C" fn() -> PluginInfo, - - /// Return list of providers this plugin offers - pub providers: extern "C" fn() -> RVec, - - /// Initialize a provider by ID, returns an opaque handle - /// The handle is passed to refresh/query/drop functions - pub provider_init: extern "C" fn(provider_id: RStr<'_>) -> ProviderHandle, - - /// Refresh a static provider's items - /// Called once at startup and when user requests refresh - pub provider_refresh: extern "C" fn(handle: ProviderHandle) -> RVec, - - /// Query a dynamic provider - /// Called on each keystroke for dynamic providers - pub provider_query: extern "C" fn(handle: ProviderHandle, query: RStr<'_>) -> RVec, - - /// Clean up a provider handle - pub provider_drop: extern "C" fn(handle: ProviderHandle), -} - -/// Opaque handle to a provider instance -/// Plugins can use this to store state between calls -#[repr(C)] -#[derive(StableAbi, Clone, Copy, Debug)] -pub struct ProviderHandle { - /// Opaque pointer to provider state - pub ptr: *mut (), -} - -impl ProviderHandle { - /// Create a null handle - pub fn null() -> Self { - Self { - ptr: std::ptr::null_mut(), - } - } - - /// Create a handle from a boxed value - /// The caller is responsible for calling drop to free the memory - pub fn from_box(value: Box) -> Self { - Self { - ptr: Box::into_raw(value) as *mut (), - } - } - - /// Convert handle back to a reference (unsafe) - /// - /// # Safety - /// The handle must have been created from a Box of the same type - pub unsafe fn as_ref(&self) -> Option<&T> { - // SAFETY: Caller guarantees the pointer was created from Box - unsafe { (self.ptr as *const T).as_ref() } - } - - /// Convert handle back to a mutable reference (unsafe) - /// - /// # Safety - /// The handle must have been created from a Box of the same type - pub unsafe fn as_mut(&mut self) -> Option<&mut T> { - // SAFETY: Caller guarantees the pointer was created from Box - unsafe { (self.ptr as *mut T).as_mut() } - } - - /// Drop the handle and free its memory (unsafe) - /// - /// # Safety - /// The handle must have been created from a Box of the same type - /// and must not be used after this call - pub unsafe fn drop_as(self) { - if !self.ptr.is_null() { - // SAFETY: Caller guarantees the pointer was created from Box - unsafe { drop(Box::from_raw(self.ptr as *mut T)) }; - } - } -} - -// ProviderHandle contains a raw pointer but we manage it carefully -unsafe impl Send for ProviderHandle {} -unsafe impl Sync for ProviderHandle {} - -// ============================================================================ -// Host API - Functions the host provides to plugins -// ============================================================================ - -/// Notification urgency level -#[repr(C)] -#[derive(StableAbi, Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum NotifyUrgency { - /// Low priority notification - Low = 0, - /// Normal priority notification (default) - #[default] - Normal = 1, - /// Critical/urgent notification - Critical = 2, -} - -/// Host API function table -/// -/// This structure contains functions that the host (owlry) provides to plugins. -/// Plugins can call these functions to interact with the system. -#[repr(C)] -#[derive(StableAbi, Clone, Copy)] -pub struct HostAPI { - /// Send a notification to the user - /// Parameters: summary, body, icon (optional, empty string for none), urgency - pub notify: - extern "C" fn(summary: RStr<'_>, body: RStr<'_>, icon: RStr<'_>, urgency: NotifyUrgency), - - /// Log a message at info level - pub log_info: extern "C" fn(message: RStr<'_>), - - /// Log a message at warning level - pub log_warn: extern "C" fn(message: RStr<'_>), - - /// Log a message at error level - pub log_error: extern "C" fn(message: RStr<'_>), - - /// Read a string value from this plugin's config section. - /// Parameters: plugin_id (the calling plugin's ID), key - /// Returns RSome(value) if set, RNone otherwise. - pub get_config_string: - extern "C" fn(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption, - - /// Read an integer value from this plugin's config section. - pub get_config_int: extern "C" fn(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption, - - /// Read a boolean value from this plugin's config section. - pub get_config_bool: extern "C" fn(plugin_id: RStr<'_>, key: RStr<'_>) -> ROption, -} - -use std::sync::OnceLock; - -// Global host API pointer - set by the host when loading plugins -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() -} - -// ============================================================================ -// Convenience functions for plugins -// ============================================================================ - -/// Send a notification (convenience wrapper) -pub fn notify(summary: &str, body: &str) { - if let Some(api) = host_api() { - (api.notify)( - RStr::from_str(summary), - RStr::from_str(body), - RStr::from_str(""), - NotifyUrgency::Normal, - ); - } -} - -/// Send a notification with an icon (convenience wrapper) -pub fn notify_with_icon(summary: &str, body: &str, icon: &str) { - if let Some(api) = host_api() { - (api.notify)( - RStr::from_str(summary), - RStr::from_str(body), - RStr::from_str(icon), - NotifyUrgency::Normal, - ); - } -} - -/// Send a notification with full options (convenience wrapper) -pub fn notify_with_urgency(summary: &str, body: &str, icon: &str, urgency: NotifyUrgency) { - if let Some(api) = host_api() { - (api.notify)( - RStr::from_str(summary), - RStr::from_str(body), - RStr::from_str(icon), - urgency, - ); - } -} - -/// Log an info message (convenience wrapper) -pub fn log_info(message: &str) { - if let Some(api) = host_api() { - (api.log_info)(RStr::from_str(message)); - } -} - -/// Log a warning message (convenience wrapper) -pub fn log_warn(message: &str) { - if let Some(api) = host_api() { - (api.log_warn)(RStr::from_str(message)); - } -} - -/// Log an error message (convenience wrapper) -pub fn log_error(message: &str) { - if let Some(api) = host_api() { - (api.log_error)(RStr::from_str(message)); - } -} - -/// Read a string value from this plugin's config section (convenience wrapper). -/// `plugin_id` must match the ID the plugin declares in its `PluginInfo`. -pub fn get_config_string(plugin_id: &str, key: &str) -> Option { - host_api().and_then(|api| { - (api.get_config_string)(RStr::from_str(plugin_id), RStr::from_str(key)) - .into_option() - .map(|s| s.into_string()) - }) -} - -/// Read an integer value from this plugin's config section (convenience wrapper). -pub fn get_config_int(plugin_id: &str, key: &str) -> Option { - host_api().and_then(|api| { - (api.get_config_int)(RStr::from_str(plugin_id), RStr::from_str(key)).into_option() - }) -} - -/// Read a boolean value from this plugin's config section (convenience wrapper). -pub fn get_config_bool(plugin_id: &str, key: &str) -> Option { - host_api().and_then(|api| { - (api.get_config_bool)(RStr::from_str(plugin_id), RStr::from_str(key)).into_option() - }) -} - -/// Helper macro for defining plugin vtables -/// -/// Usage: -/// ```ignore -/// owlry_plugin! { -/// info: my_plugin_info, -/// providers: my_providers, -/// init: my_init, -/// refresh: my_refresh, -/// query: my_query, -/// drop: my_drop, -/// } -/// ``` -#[macro_export] -macro_rules! owlry_plugin { - ( - info: $info:expr, - providers: $providers:expr, - init: $init:expr, - refresh: $refresh:expr, - query: $query:expr, - drop: $drop:expr $(,)? - ) => { - static OWLRY_PLUGIN_VTABLE: $crate::PluginVTable = $crate::PluginVTable { - info: $info, - providers: $providers, - provider_init: $init, - provider_refresh: $refresh, - provider_query: $query, - provider_drop: $drop, - }; - - #[unsafe(no_mangle)] - pub extern "C" fn owlry_plugin_vtable() -> &'static $crate::PluginVTable { - &OWLRY_PLUGIN_VTABLE - } - }; -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_plugin_item_builder() { - let item = PluginItem::new("test-id", "Test Item", "echo hello") - .with_description("A test item") - .with_icon("test-icon") - .with_terminal(true) - .with_keywords(vec!["test".to_string(), "example".to_string()]) - .with_score_boost(100); - - assert_eq!(item.id.as_str(), "test-id"); - assert_eq!(item.name.as_str(), "Test Item"); - assert_eq!(item.command.as_str(), "echo hello"); - assert!(item.terminal); - assert_eq!(item.score_boost, 100); - } - - #[test] - fn test_provider_handle() { - let value = Box::new(42i32); - let handle = ProviderHandle::from_box(value); - - unsafe { - assert_eq!(*handle.as_ref::().unwrap(), 42); - handle.drop_as::(); - } - } -} diff --git a/crates/owlry-rune/Cargo.toml b/crates/owlry-rune/Cargo.toml deleted file mode 100644 index 660abff..0000000 --- a/crates/owlry-rune/Cargo.toml +++ /dev/null @@ -1,40 +0,0 @@ -[package] -name = "owlry-rune" -version = "1.1.6" -edition = "2024" -rust-version = "1.90" -description = "Rune scripting runtime for owlry plugins" -license = "GPL-3.0-or-later" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -# Shared plugin API -owlry-plugin-api = { path = "../owlry-plugin-api" } - -# Rune scripting language -rune = "0.14" - -# Logging -log = "0.4" -env_logger = "0.11" - -# Serialization -serde = { version = "1", features = ["derive"] } -serde_json = "1" - -# Configuration parsing -toml = "0.8" - -# Semantic versioning -semver = "1" - -# Date/time -chrono = "0.4" - -# Directory paths -dirs = "5" - -[dev-dependencies] -tempfile = "3" diff --git a/crates/owlry-rune/src/api.rs b/crates/owlry-rune/src/api.rs deleted file mode 100644 index 3fe3044..0000000 --- a/crates/owlry-rune/src/api.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! Owlry API bindings for Rune plugins -//! -//! This module provides the `owlry` module that Rune plugins can use. - -use rune::{Any, ContextError, Module}; -use std::sync::Mutex; - -use owlry_plugin_api::{PluginItem, RString}; - -/// Provider registration info -#[derive(Debug, Clone)] -pub struct ProviderRegistration { - pub name: String, - pub display_name: String, - pub type_id: String, - pub default_icon: String, - pub is_static: bool, - pub prefix: Option, - pub tab_label: Option, - pub search_noun: Option, -} - -/// An item returned by a provider -/// -/// Exposed to Rune scripts as `owlry::Item`. -#[derive(Debug, Clone, Any)] -#[rune(item = ::owlry)] -pub struct Item { - pub id: String, - pub name: String, - pub description: Option, - pub icon: Option, - pub command: String, - pub terminal: bool, - pub keywords: Vec, -} - -impl Item { - /// Constructor exposed to Rune via #[rune::function] - #[rune::function(path = Self::new)] - pub fn rune_new(id: String, name: String, command: String) -> Self { - Self { - id, - name, - command, - description: None, - icon: None, - terminal: false, - keywords: Vec::new(), - } - } - - /// Set description (builder pattern for Rune) - #[rune::function] - fn description(mut self, desc: String) -> Self { - self.description = Some(desc); - self - } - - /// Set icon (builder pattern for Rune) - #[rune::function] - fn icon(mut self, icon: String) -> Self { - self.icon = Some(icon); - self - } - - /// Set keywords (builder pattern for Rune) - #[rune::function] - fn keywords(mut self, keywords: Vec) -> Self { - self.keywords = keywords; - self - } - - /// Convert to PluginItem for FFI - pub fn to_plugin_item(&self) -> PluginItem { - let mut item = PluginItem::new( - RString::from(self.id.as_str()), - RString::from(self.name.as_str()), - RString::from(self.command.as_str()), - ); - - if let Some(ref desc) = self.description { - item = item.with_description(desc.clone()); - } - if let Some(ref icon) = self.icon { - item = item.with_icon(icon.clone()); - } - - item.with_terminal(self.terminal) - .with_keywords(self.keywords.clone()) - } -} - -/// Global state for provider registrations (thread-safe) -pub static REGISTRATIONS: Mutex> = Mutex::new(Vec::new()); - -/// Create the owlry module for Rune -pub fn module() -> Result { - let mut module = Module::with_crate("owlry")?; - - // Register Item type with constructor and builder methods - module.ty::()?; - module.function_meta(Item::rune_new)?; - module.function_meta(Item::description)?; - module.function_meta(Item::icon)?; - module.function_meta(Item::keywords)?; - - // Register logging functions - module.function("log_info", log_info).build()?; - module.function("log_debug", log_debug).build()?; - module.function("log_warn", log_warn).build()?; - module.function("log_error", log_error).build()?; - - Ok(module) -} - -// ============================================================================ -// Logging Functions -// ============================================================================ - -fn log_info(message: &str) { - log::info!("[Rune] {}", message); -} - -fn log_debug(message: &str) { - log::debug!("[Rune] {}", message); -} - -fn log_warn(message: &str) { - log::warn!("[Rune] {}", message); -} - -fn log_error(message: &str) { - log::error!("[Rune] {}", message); -} - -/// Get all provider registrations -pub fn get_registrations() -> Vec { - REGISTRATIONS.lock().unwrap().clone() -} - -/// Clear all registrations (for testing or reloading) -pub fn clear_registrations() { - REGISTRATIONS.lock().unwrap().clear(); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_item_creation() { - let item = Item { - id: "test-1".to_string(), - name: "Test Item".to_string(), - description: Some("A test".to_string()), - icon: Some("test-icon".to_string()), - command: "echo test".to_string(), - terminal: false, - keywords: vec!["test".to_string()], - }; - - let plugin_item = item.to_plugin_item(); - assert_eq!(plugin_item.id.as_str(), "test-1"); - assert_eq!(plugin_item.name.as_str(), "Test Item"); - } - - #[test] - fn test_module_creation() { - let module = module(); - assert!(module.is_ok()); - } -} diff --git a/crates/owlry-rune/src/lib.rs b/crates/owlry-rune/src/lib.rs deleted file mode 100644 index ef7749b..0000000 --- a/crates/owlry-rune/src/lib.rs +++ /dev/null @@ -1,276 +0,0 @@ -//! Owlry Rune Runtime -//! -//! This crate provides a Rune scripting runtime for owlry user plugins. -//! It is loaded dynamically by the core when installed. -//! -//! # Architecture -//! -//! The runtime exports a C-compatible vtable that the core uses to: -//! 1. Initialize the runtime with a plugins directory -//! 2. Get a list of providers from loaded plugins -//! 3. Refresh/query providers -//! 4. Clean up resources -//! -//! # Plugin Structure -//! -//! Rune plugins live in `~/.config/owlry/plugins//`: -//! ```text -//! my-plugin/ -//! plugin.toml # Manifest -//! init.rn # Entry point (Rune script) -//! ``` - -mod api; -mod loader; -mod manifest; -mod runtime; - -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Mutex; - -use owlry_plugin_api::{PluginItem, ROption, RStr, RString, RVec}; - -pub use loader::LoadedPlugin; -pub use manifest::PluginManifest; - -// ============================================================================ -// Runtime VTable (C-compatible interface) -// ============================================================================ - -/// Information about this runtime -#[repr(C)] -pub struct RuntimeInfo { - pub name: RString, - pub version: RString, -} - -/// Information about a provider from a plugin -#[repr(C)] -#[derive(Clone)] -pub struct RuneProviderInfo { - pub name: RString, - pub display_name: RString, - pub type_id: RString, - pub default_icon: RString, - pub is_static: bool, - pub prefix: ROption, - pub tab_label: ROption, - pub search_noun: ROption, -} - -/// Opaque handle to runtime state -#[repr(transparent)] -#[derive(Clone, Copy)] -pub struct RuntimeHandle(pub *mut ()); - -/// Runtime state managed by the handle -struct RuntimeState { - plugins: HashMap, - providers: Vec, -} - -/// VTable for the Rune runtime -#[repr(C)] -pub struct RuneRuntimeVTable { - pub info: extern "C" fn() -> RuntimeInfo, - pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle, - pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec, - pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec, - pub query: extern "C" fn( - handle: RuntimeHandle, - provider_id: RStr<'_>, - query: RStr<'_>, - ) -> RVec, - pub drop: extern "C" fn(handle: RuntimeHandle), -} - -// ============================================================================ -// VTable Implementation -// ============================================================================ - -extern "C" fn runtime_info() -> RuntimeInfo { - RuntimeInfo { - name: RString::from("rune"), - version: RString::from(env!("CARGO_PKG_VERSION")), - } -} - -extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle { - let _ = env_logger::try_init(); - let _version = owlry_version.as_str(); - - let plugins_dir = PathBuf::from(plugins_dir.as_str()); - log::info!( - "Initializing Rune runtime with plugins from: {}", - plugins_dir.display() - ); - - let mut state = RuntimeState { - plugins: HashMap::new(), - providers: Vec::new(), - }; - - // Discover and load Rune plugins - match loader::discover_rune_plugins(&plugins_dir) { - Ok(plugins) => { - for (id, plugin) in plugins { - // Collect provider info before storing plugin - for reg in plugin.provider_registrations() { - state.providers.push(RuneProviderInfo { - name: RString::from(reg.name.as_str()), - display_name: RString::from(reg.display_name.as_str()), - type_id: RString::from(reg.type_id.as_str()), - default_icon: RString::from(reg.default_icon.as_str()), - is_static: reg.is_static, - prefix: reg - .prefix - .as_ref() - .map(|p| RString::from(p.as_str())) - .into(), - tab_label: reg - .tab_label - .as_ref() - .map(|s| RString::from(s.as_str())) - .into(), - search_noun: reg - .search_noun - .as_ref() - .map(|s| RString::from(s.as_str())) - .into(), - }); - } - state.plugins.insert(id, plugin); - } - log::info!( - "Loaded {} Rune plugin(s) with {} provider(s)", - state.plugins.len(), - state.providers.len() - ); - } - Err(e) => { - log::error!("Failed to discover Rune plugins: {}", e); - } - } - - // Box and leak the state, returning an opaque handle - let boxed = Box::new(Mutex::new(state)); - RuntimeHandle(Box::into_raw(boxed) as *mut ()) -} - -extern "C" fn runtime_providers(handle: RuntimeHandle) -> RVec { - let state = unsafe { &*(handle.0 as *const Mutex) }; - let guard = state.lock().unwrap(); - guard.providers.clone().into_iter().collect() -} - -extern "C" fn runtime_refresh(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec { - let state = unsafe { &*(handle.0 as *const Mutex) }; - let mut guard = state.lock().unwrap(); - - let provider_name = provider_id.as_str(); - - // Find the plugin that provides this provider - for plugin in guard.plugins.values_mut() { - if plugin.provides_provider(provider_name) { - match plugin.refresh_provider(provider_name) { - Ok(items) => return items.into_iter().collect(), - Err(e) => { - log::error!("Failed to refresh provider '{}': {}", provider_name, e); - return RVec::new(); - } - } - } - } - - log::warn!("Provider '{}' not found", provider_name); - RVec::new() -} - -extern "C" fn runtime_query( - handle: RuntimeHandle, - provider_id: RStr<'_>, - query: RStr<'_>, -) -> RVec { - let state = unsafe { &*(handle.0 as *const Mutex) }; - let mut guard = state.lock().unwrap(); - - let provider_name = provider_id.as_str(); - let query_str = query.as_str(); - - // Find the plugin that provides this provider - for plugin in guard.plugins.values_mut() { - if plugin.provides_provider(provider_name) { - match plugin.query_provider(provider_name, query_str) { - Ok(items) => return items.into_iter().collect(), - Err(e) => { - log::error!("Failed to query provider '{}': {}", provider_name, e); - return RVec::new(); - } - } - } - } - - log::warn!("Provider '{}' not found", provider_name); - RVec::new() -} - -extern "C" fn runtime_drop(handle: RuntimeHandle) { - if !handle.0.is_null() { - // SAFETY: We created this box in runtime_init - unsafe { - let _ = Box::from_raw(handle.0 as *mut Mutex); - } - log::info!("Rune runtime cleaned up"); - } -} - -/// Static vtable instance -static RUNE_RUNTIME_VTABLE: RuneRuntimeVTable = RuneRuntimeVTable { - info: runtime_info, - init: runtime_init, - providers: runtime_providers, - refresh: runtime_refresh, - query: runtime_query, - drop: runtime_drop, -}; - -/// Entry point - returns the runtime vtable -#[unsafe(no_mangle)] -pub extern "C" fn owlry_rune_runtime_vtable() -> &'static RuneRuntimeVTable { - &RUNE_RUNTIME_VTABLE -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_runtime_info() { - let info = runtime_info(); - assert_eq!(info.name.as_str(), "rune"); - assert!(!info.version.as_str().is_empty()); - } - - #[test] - fn test_runtime_lifecycle() { - // Create a temp directory for plugins - let temp = tempfile::TempDir::new().unwrap(); - let plugins_dir = temp.path().to_string_lossy(); - - // Initialize runtime - let handle = runtime_init(RStr::from_str(&plugins_dir), RStr::from_str("1.0.0")); - assert!(!handle.0.is_null()); - - // Get providers (should be empty with no plugins) - let providers = runtime_providers(handle); - assert!(providers.is_empty()); - - // Clean up - runtime_drop(handle); - } -} diff --git a/crates/owlry-rune/src/loader.rs b/crates/owlry-rune/src/loader.rs deleted file mode 100644 index 136dc35..0000000 --- a/crates/owlry-rune/src/loader.rs +++ /dev/null @@ -1,294 +0,0 @@ -//! Rune plugin discovery and loading - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use rune::{Context, Unit}; - -use crate::api::{self, ProviderRegistration}; -use crate::manifest::PluginManifest; -use crate::runtime::{SandboxConfig, compile_source, create_context, create_vm}; - -use owlry_plugin_api::PluginItem; - -/// A loaded Rune plugin -pub struct LoadedPlugin { - pub manifest: PluginManifest, - pub path: PathBuf, - /// Context for creating new VMs (reserved for refresh/query implementation) - #[allow(dead_code)] - context: Context, - /// Compiled unit (reserved for refresh/query implementation) - #[allow(dead_code)] - unit: Arc, - registrations: Vec, -} - -impl LoadedPlugin { - /// Create and initialize a new plugin - pub fn new(manifest: PluginManifest, path: PathBuf) -> Result { - let sandbox = SandboxConfig::from_permissions(&manifest.permissions); - let context = - create_context(&sandbox).map_err(|e| format!("Failed to create context: {}", e))?; - - let entry_path = path.join(&manifest.plugin.entry); - if !entry_path.exists() { - return Err(format!("Entry point not found: {}", entry_path.display())); - } - - // Clear previous registrations before loading - api::clear_registrations(); - - // Compile the source - let unit = compile_source(&context, &entry_path) - .map_err(|e| format!("Failed to compile: {}", e))?; - - // Run the entry point to register providers - let mut vm = - create_vm(&context, unit.clone()).map_err(|e| format!("Failed to create VM: {}", e))?; - - // Execute the main function if it exists - match vm.call(rune::Hash::type_hash(["main"]), ()) { - Ok(result) => { - // Try to complete the execution - let _: () = rune::from_value(result).unwrap_or(()); - } - Err(_) => { - // No main function is okay - } - } - - // Collect registrations — from runtime API or from manifest [[providers]] - let mut registrations = api::get_registrations(); - if registrations.is_empty() && !manifest.providers.is_empty() { - for decl in &manifest.providers { - registrations.push(ProviderRegistration { - name: decl.id.clone(), - display_name: decl.name.clone(), - type_id: decl.type_id.clone().unwrap_or_else(|| decl.id.clone()), - default_icon: decl.icon.clone().unwrap_or_else(|| "application-x-addon".to_string()), - is_static: decl.provider_type != "dynamic", - prefix: decl.prefix.clone(), - tab_label: decl.tab_label.clone(), - search_noun: decl.search_noun.clone(), - }); - } - } - - log::info!( - "Loaded Rune plugin '{}' with {} provider(s)", - manifest.plugin.id, - registrations.len() - ); - - Ok(Self { - manifest, - path, - context, - unit, - registrations, - }) - } - - /// Get plugin ID - pub fn id(&self) -> &str { - &self.manifest.plugin.id - } - - /// Get provider registrations - pub fn provider_registrations(&self) -> &[ProviderRegistration] { - &self.registrations - } - - /// Check if this plugin provides a specific provider - pub fn provides_provider(&self, name: &str) -> bool { - self.registrations.iter().any(|r| r.name == name) - } - - /// Refresh a static provider by calling the Rune `refresh()` function - pub fn refresh_provider(&mut self, _name: &str) -> Result, String> { - let mut vm = create_vm(&self.context, self.unit.clone()) - .map_err(|e| format!("Failed to create VM: {}", e))?; - - let output = vm - .call(rune::Hash::type_hash(["refresh"]), ()) - .map_err(|e| format!("refresh() call failed: {}", e))?; - - let items: Vec = rune::from_value(output) - .map_err(|e| format!("Failed to parse refresh() result: {}", e))?; - - Ok(items.iter().map(|i| i.to_plugin_item()).collect()) - } - - /// Query a dynamic provider by calling the Rune `query(q)` function - pub fn query_provider(&mut self, _name: &str, query: &str) -> Result, String> { - let mut vm = create_vm(&self.context, self.unit.clone()) - .map_err(|e| format!("Failed to create VM: {}", e))?; - - let output = vm - .call( - rune::Hash::type_hash(["query"]), - (query.to_string(),), - ) - .map_err(|e| format!("query() call failed: {}", e))?; - - let items: Vec = rune::from_value(output) - .map_err(|e| format!("Failed to parse query() result: {}", e))?; - - Ok(items.iter().map(|i| i.to_plugin_item()).collect()) - } -} - -/// Discover Rune plugins in a directory -pub fn discover_rune_plugins(plugins_dir: &Path) -> Result, String> { - let mut plugins = HashMap::new(); - - if !plugins_dir.exists() { - log::debug!( - "Plugins directory does not exist: {}", - plugins_dir.display() - ); - return Ok(plugins); - } - - let entries = std::fs::read_dir(plugins_dir) - .map_err(|e| format!("Failed to read plugins directory: {}", e))?; - - for entry in entries { - let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; - let path = entry.path(); - - if !path.is_dir() { - continue; - } - - let manifest_path = path.join("plugin.toml"); - if !manifest_path.exists() { - continue; - } - - // Load manifest - let manifest = match PluginManifest::load(&manifest_path) { - Ok(m) => m, - Err(e) => { - log::warn!( - "Failed to load manifest at {}: {}", - manifest_path.display(), - e - ); - continue; - } - }; - - // Check if this is a Rune plugin (entry ends with .rn) - if !manifest.plugin.entry.ends_with(".rn") { - log::debug!("Skipping non-Rune plugin: {}", manifest.plugin.id); - continue; - } - - // Load the plugin - match LoadedPlugin::new(manifest.clone(), path.clone()) { - Ok(plugin) => { - let id = manifest.plugin.id.clone(); - plugins.insert(id, plugin); - } - Err(e) => { - log::warn!("Failed to load plugin '{}': {}", manifest.plugin.id, e); - } - } - } - - Ok(plugins) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - #[test] - fn test_discover_empty_dir() { - let temp = TempDir::new().unwrap(); - let plugins = discover_rune_plugins(temp.path()).unwrap(); - assert!(plugins.is_empty()); - } - - #[test] - fn test_discover_skips_non_rune_plugins() { - let temp = TempDir::new().unwrap(); - let plugins_dir = temp.path(); - - // Lua plugin — should be skipped by the Rune runtime - let lua_dir = plugins_dir.join("lua-plugin"); - fs::create_dir_all(&lua_dir).unwrap(); - fs::write( - lua_dir.join("plugin.toml"), - r#" -[plugin] -id = "lua-plugin" -name = "Lua Plugin" -version = "1.0.0" -entry_point = "main.lua" - -[[providers]] -id = "lua-plugin" -name = "Lua Plugin" -"#, - ) - .unwrap(); - fs::write(lua_dir.join("main.lua"), "function refresh() return {} end").unwrap(); - - let plugins = discover_rune_plugins(plugins_dir).unwrap(); - assert!(plugins.is_empty(), "Lua plugin should be skipped by Rune runtime"); - } - - #[test] - fn test_manifest_provider_fallback() { - let temp = TempDir::new().unwrap(); - let plugin_dir = temp.path().join("test-plugin"); - fs::create_dir_all(&plugin_dir).unwrap(); - - fs::write( - plugin_dir.join("plugin.toml"), - r#" -[plugin] -id = "test-plugin" -name = "Test Plugin" -version = "1.0.0" -entry_point = "main.rn" - -[[providers]] -id = "test-plugin" -name = "Test Plugin" -type = "static" -type_id = "testplugin" -icon = "system-run" -prefix = ":tp" -"#, - ) - .unwrap(); - // Script that exports refresh() but doesn't call register_provider() - fs::write( - plugin_dir.join("main.rn"), - r#"use owlry::Item; -pub fn refresh() { - [] -} -"#, - ) - .unwrap(); - - let manifest = - crate::manifest::PluginManifest::load(&plugin_dir.join("plugin.toml")).unwrap(); - let plugin = LoadedPlugin::new(manifest, plugin_dir).unwrap(); - - let regs = plugin.provider_registrations(); - assert_eq!(regs.len(), 1, "should fall back to [[providers]] declaration"); - assert_eq!(regs[0].name, "test-plugin"); - assert_eq!(regs[0].type_id, "testplugin"); - assert_eq!(regs[0].prefix.as_deref(), Some(":tp")); - assert!(regs[0].is_static); - } -} diff --git a/crates/owlry-rune/src/manifest.rs b/crates/owlry-rune/src/manifest.rs deleted file mode 100644 index 1929a90..0000000 --- a/crates/owlry-rune/src/manifest.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! Plugin manifest parsing for Rune plugins - -use serde::Deserialize; -use std::path::Path; - -/// Plugin manifest from plugin.toml -#[derive(Debug, Clone, Deserialize)] -pub struct PluginManifest { - pub plugin: PluginInfo, - #[serde(default)] - pub provides: PluginProvides, - #[serde(default)] - pub permissions: PluginPermissions, - /// Provider declarations from [[providers]] sections - #[serde(default)] - pub providers: Vec, -} - -/// A provider declared in [[providers]] section of plugin.toml -#[derive(Debug, Clone, Deserialize)] -pub struct ProviderDecl { - pub id: String, - pub name: String, - #[serde(default)] - pub prefix: Option, - #[serde(default)] - pub icon: Option, - #[serde(default = "default_provider_type", rename = "type")] - pub provider_type: String, - #[serde(default)] - pub type_id: Option, - #[serde(default)] - pub tab_label: Option, - #[serde(default)] - pub search_noun: Option, -} - -fn default_provider_type() -> String { - "static".to_string() -} - -/// Core plugin information -#[derive(Debug, Clone, Deserialize)] -pub struct PluginInfo { - pub id: String, - pub name: String, - pub version: String, - #[serde(default)] - pub description: String, - #[serde(default)] - pub author: String, - #[serde(default = "default_owlry_version")] - pub owlry_version: String, - #[serde(default = "default_entry", alias = "entry_point")] - pub entry: String, -} - -fn default_owlry_version() -> String { - ">=0.1.0".to_string() -} - -fn default_entry() -> String { - "main.rn".to_string() -} - -/// What the plugin provides -#[derive(Debug, Clone, Default, Deserialize)] -pub struct PluginProvides { - #[serde(default)] - pub providers: Vec, - #[serde(default)] - pub actions: bool, - #[serde(default)] - pub themes: Vec, - #[serde(default)] - pub hooks: bool, -} - -/// Plugin permissions -#[derive(Debug, Clone, Default, Deserialize)] -pub struct PluginPermissions { - #[serde(default)] - pub network: bool, - #[serde(default)] - pub filesystem: Vec, - #[serde(default)] - pub run_commands: Vec, -} - -impl PluginManifest { - /// Load manifest from a plugin.toml file - pub fn load(path: &Path) -> Result { - let content = - std::fs::read_to_string(path).map_err(|e| format!("Failed to read manifest: {}", e))?; - let manifest: PluginManifest = - toml::from_str(&content).map_err(|e| format!("Failed to parse manifest: {}", e))?; - manifest.validate()?; - Ok(manifest) - } - - /// Validate the manifest - fn validate(&self) -> Result<(), String> { - if self.plugin.id.is_empty() { - return Err("Plugin ID cannot be empty".to_string()); - } - - if !self - .plugin - .id - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - { - return Err("Plugin ID must be lowercase alphanumeric with hyphens".to_string()); - } - - // Validate version format - if semver::Version::parse(&self.plugin.version).is_err() { - return Err(format!("Invalid version format: {}", self.plugin.version)); - } - - // Rune plugins must have .rn entry point - if !self.plugin.entry.ends_with(".rn") { - return Err("Entry point must be a .rn file for Rune plugins".to_string()); - } - - Ok(()) - } - - /// Check compatibility with owlry version - pub fn is_compatible_with(&self, owlry_version: &str) -> bool { - let req = match semver::VersionReq::parse(&self.plugin.owlry_version) { - Ok(r) => r, - Err(_) => return false, - }; - let version = match semver::Version::parse(owlry_version) { - Ok(v) => v, - Err(_) => return false, - }; - req.matches(&version) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_minimal_manifest() { - let toml_str = r#" -[plugin] -id = "test-plugin" -name = "Test Plugin" -version = "1.0.0" -"#; - let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); - assert_eq!(manifest.plugin.id, "test-plugin"); - assert_eq!(manifest.plugin.entry, "main.rn"); - } - - #[test] - fn test_validate_entry_point() { - let toml_str = r#" -[plugin] -id = "test" -name = "Test" -version = "1.0.0" -entry = "main.lua" -"#; - let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); - assert!(manifest.validate().is_err()); // .lua not allowed for Rune - } - - #[test] - fn test_version_compatibility() { - let toml_str = r#" -[plugin] -id = "test" -name = "Test" -version = "1.0.0" -owlry_version = ">=0.3.0" -"#; - let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); - assert!(manifest.is_compatible_with("0.3.5")); - assert!(!manifest.is_compatible_with("0.2.0")); - } -} diff --git a/crates/owlry-rune/src/runtime.rs b/crates/owlry-rune/src/runtime.rs deleted file mode 100644 index 8c7e9f0..0000000 --- a/crates/owlry-rune/src/runtime.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Rune VM runtime creation and sandboxing - -use rune::{Context, Diagnostics, Source, Sources, Unit, Vm}; -use std::path::Path; -use std::sync::Arc; - -use crate::manifest::PluginPermissions; - -/// Configuration for the Rune sandbox -/// -/// Some fields are reserved for future sandbox enforcement. -#[derive(Debug, Clone)] -#[allow(dead_code)] -#[derive(Default)] -pub struct SandboxConfig { - /// Allow network/HTTP operations - pub network: bool, - /// Allow filesystem operations - pub filesystem: bool, - /// Allowed filesystem paths (reserved for future sandbox enforcement) - pub allowed_paths: Vec, - /// Allow running external commands (reserved for future sandbox enforcement) - pub run_commands: bool, - /// Allowed commands (reserved for future sandbox enforcement) - pub allowed_commands: Vec, -} - -impl SandboxConfig { - /// Create sandbox config from plugin permissions - pub fn from_permissions(permissions: &PluginPermissions) -> Self { - Self { - network: permissions.network, - filesystem: !permissions.filesystem.is_empty(), - allowed_paths: permissions.filesystem.clone(), - run_commands: !permissions.run_commands.is_empty(), - allowed_commands: permissions.run_commands.clone(), - } - } -} - -/// Create a Rune context with owlry API modules -pub fn create_context(sandbox: &SandboxConfig) -> Result { - let mut context = Context::with_default_modules()?; - - // Add standard modules based on permissions - if sandbox.network { - log::debug!("Network access enabled for Rune plugin"); - } - - if sandbox.filesystem { - log::debug!("Filesystem access enabled for Rune plugin"); - } - - // Add owlry API module - context.install(crate::api::module()?)?; - - Ok(context) -} - -/// Compile Rune source code into a Unit -pub fn compile_source(context: &Context, source_path: &Path) -> Result, CompileError> { - let source_content = - std::fs::read_to_string(source_path).map_err(|e| CompileError::Io(e.to_string()))?; - - let source_name = source_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("init.rn"); - - let mut sources = Sources::new(); - sources - .insert( - Source::new(source_name, &source_content) - .map_err(|e| CompileError::Compile(e.to_string()))?, - ) - .map_err(|e| CompileError::Compile(format!("Failed to insert source: {}", e)))?; - - let mut diagnostics = Diagnostics::new(); - - let result = rune::prepare(&mut sources) - .with_context(context) - .with_diagnostics(&mut diagnostics) - .build(); - - match result { - Ok(unit) => Ok(Arc::new(unit)), - Err(e) => { - // Collect error messages - let mut error_msg = format!("Compilation failed: {}", e); - for diagnostic in diagnostics.diagnostics() { - error_msg.push_str(&format!("\n {:?}", diagnostic)); - } - Err(CompileError::Compile(error_msg)) - } - } -} - -/// Create a new Rune VM from compiled unit -pub fn create_vm(context: &Context, unit: Arc) -> Result { - let runtime = Arc::new( - context - .runtime() - .map_err(|e| CompileError::Compile(format!("Failed to get runtime: {}", e)))?, - ); - Ok(Vm::new(runtime, unit)) -} - -/// Error type for compilation -#[derive(Debug)] -pub enum CompileError { - Io(String), - Compile(String), -} - -impl std::fmt::Display for CompileError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - CompileError::Io(e) => write!(f, "IO error: {}", e), - CompileError::Compile(e) => write!(f, "Compile error: {}", e), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_sandbox_config_default() { - let config = SandboxConfig::default(); - assert!(!config.network); - assert!(!config.filesystem); - assert!(!config.run_commands); - } - - #[test] - fn test_sandbox_from_permissions() { - let permissions = PluginPermissions { - network: true, - filesystem: vec!["~/.config".to_string()], - run_commands: vec!["notify-send".to_string()], - }; - let config = SandboxConfig::from_permissions(&permissions); - assert!(config.network); - assert!(config.filesystem); - assert!(config.run_commands); - assert_eq!(config.allowed_paths, vec!["~/.config"]); - assert_eq!(config.allowed_commands, vec!["notify-send"]); - } - - #[test] - fn test_create_context() { - let config = SandboxConfig::default(); - let context = create_context(&config); - assert!(context.is_ok()); - } -} diff --git a/crates/owlry/Cargo.toml b/crates/owlry/Cargo.toml index 7a849b7..506acad 100644 --- a/crates/owlry/Cargo.toml +++ b/crates/owlry/Cargo.toml @@ -34,18 +34,9 @@ toml = "0.8" # CLI argument parsing clap = { version = "4", features = ["derive"] } -# JSON serialization (needed by plugin commands in CLI) +# IPC (Request/Response serialization) serde_json = "1" -# Date/time (needed by plugin commands in CLI) -chrono = { version = "0.4", features = ["serde"] } - -# Directory utilities (needed by plugin commands) -dirs = "5" - -# Semantic versioning (needed by plugin commands) -semver = "1" - # Async oneshot channel (background thread -> main loop) futures-channel = "0.3" @@ -57,5 +48,3 @@ glib-build-tools = "0.20" default = [] # Enable verbose debug logging (for development/testing builds) dev-logging = ["owlry-core/dev-logging"] -# Enable built-in Lua runtime (disable to use external owlry-lua package) -lua = ["owlry-core/lua"] diff --git a/crates/owlry/src/app.rs b/crates/owlry/src/app.rs index dc629a2..77fb6e3 100644 --- a/crates/owlry/src/app.rs +++ b/crates/owlry/src/app.rs @@ -144,41 +144,19 @@ impl OwlryApp { } /// Create a local backend as fallback when daemon is unavailable. - /// Loads native plugins and creates providers locally. - fn create_local_backend(config: &Config) -> SearchBackend { - use owlry_core::plugins::native_loader::NativePluginLoader; - use owlry_core::providers::native_provider::NativeProvider; + /// + /// Only built-in providers run locally — dmenu mode is the typical use case. + /// All other providers belong to the daemon (Phase 1 keeps the daemon as the + /// primary path). + fn create_local_backend(_config: &Config) -> SearchBackend { use owlry_core::providers::{ApplicationProvider, CommandProvider}; - use std::sync::Arc; - - // Load native plugins - let mut loader = NativePluginLoader::new(); - loader.set_disabled(config.plugins.disabled_plugins.clone()); - - let native_providers: Vec = match loader.discover() { - Ok(count) if count > 0 => { - info!("Discovered {} native plugin(s) for local fallback", count); - let plugins: Vec> = - loader.into_plugins(); - let mut providers = Vec::new(); - for plugin in plugins { - for provider_info in &plugin.providers { - let provider = - NativeProvider::new(Arc::clone(&plugin), provider_info.clone()); - providers.push(provider); - } - } - providers - } - _ => Vec::new(), - }; let core_providers: Vec> = vec![ Box::new(ApplicationProvider::new()), Box::new(CommandProvider::new()), ]; - let provider_manager = ProviderManager::new(core_providers, native_providers); + let provider_manager = ProviderManager::new(core_providers, Vec::new()); let frecency = FrecencyStore::load_or_default(); SearchBackend::Local { diff --git a/crates/owlry/src/backend.rs b/crates/owlry/src/backend.rs index 1e175f9..aa36184 100644 --- a/crates/owlry/src/backend.rs +++ b/crates/owlry/src/backend.rs @@ -342,12 +342,9 @@ impl SearchBackend { } } - /// Refresh widget providers. No-op for daemon mode (daemon handles refresh). - pub fn refresh_widgets(&mut self) { - if let SearchBackend::Local { providers, .. } = self { - providers.refresh_widgets(); - } - } + /// Refresh widget providers. No-op in Phase 1 (widgets deferred per D20). + /// Retained as a stable no-op so callers don't need conditional compilation. + pub fn refresh_widgets(&mut self) {} /// Get available provider descriptors from the daemon, or from local manager. pub fn available_providers(&mut self) -> Vec { diff --git a/crates/owlry/src/cli.rs b/crates/owlry/src/cli.rs index 63e7cac..107adb6 100644 --- a/crates/owlry/src/cli.rs +++ b/crates/owlry/src/cli.rs @@ -1,8 +1,6 @@ //! Command-line interface for owlry launcher -//! -//! Provides both the launcher interface and plugin management commands. -use clap::{Parser, Subcommand}; +use clap::Parser; use owlry_core::providers::ProviderType; @@ -11,17 +9,17 @@ use owlry_core::providers::ProviderType; name = "owlry", about = "An owl-themed application launcher for Wayland", long_about = "An owl-themed application launcher for Wayland, built with GTK4 and Layer Shell.\n\n\ - Owlry provides fuzzy search across applications, commands, and plugins.\n\ - Native plugins add features like calculator, clipboard, emoji, weather, and more.", + Owlry provides fuzzy search across applications, commands, and built-in providers.", version, after_help = "\ EXAMPLES: - owlry Launch with all providers + owlry Launch UI, auto mode + owlry -m auto Launch UI, auto mode (explicit) owlry -m app Applications only owlry -m cmd PATH commands only owlry -m dmenu dmenu-compatible mode (reads from stdin) owlry --profile dev Use a named profile from config - owlry -m calc Calculator plugin only (if installed) + owlry -d Run the daemon DMENU MODE: Pipe input to owlry for interactive selection: @@ -41,18 +39,22 @@ PROFILES: SEARCH PREFIXES: :app firefox Search applications :cmd git Search PATH commands - = 5+3 Calculator (requires plugin) - ? rust docs Web search (requires plugin) - / .bashrc File search (requires plugin) + :calc / = Calculator (e.g. = 2+2) + :web / ? Web search + :file / / File search -For configuration, see ~/.config/owlry/config.toml -For plugin management, see: owlry plugin --help" +For configuration, see ~/.config/owlry/config.toml" )] pub struct CliArgs { + /// Run the daemon (alias for `owlry daemon`, when subcommands land) + #[arg(long, short = 'd', conflicts_with_all = ["mode", "profile", "prompt"])] + pub daemon: bool, + /// Start in single-provider mode /// - /// Core modes: app, cmd, dmenu - /// Plugin modes: calc, clip, emoji, ssh, sys, bm, file, web, uuctl, weather, media, pomodoro + /// Core modes: app, cmd, dmenu, auto + /// Built-in modes: calc, conv, power + /// Optional modes (cargo features): bm, clip, emoji, ssh, systemd/uuctl, web, file #[arg(long, short = 'm', value_parser = parse_provider, value_name = "MODE")] pub mode: Option, @@ -69,187 +71,6 @@ pub struct CliArgs { /// Example: -p "Select file:" or --prompt "Select file:" #[arg(long, short = 'p', value_name = "TEXT")] pub prompt: Option, - - /// Subcommand to run (if any) - #[command(subcommand)] - pub command: Option, -} - -#[derive(Subcommand, Debug, Clone)] -pub enum Command { - /// Manage plugins - #[command(subcommand)] - Plugin(PluginCommand), -} - -/// Plugin runtime type -#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] -pub enum PluginRuntime { - /// Lua runtime (requires owlry-lua package) - Lua, - /// Rune runtime (requires owlry-rune package) - Rune, -} - -impl std::fmt::Display for PluginRuntime { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PluginRuntime::Lua => write!(f, "lua"), - PluginRuntime::Rune => write!(f, "rune"), - } - } -} - -#[derive(Subcommand, Debug, Clone)] -pub enum PluginCommand { - /// List installed plugins - List { - /// Show only enabled plugins - #[arg(long)] - enabled: bool, - - /// Show only disabled plugins - #[arg(long)] - disabled: bool, - - /// Filter by runtime type (lua or rune) - #[arg(long, short = 'r', value_enum)] - runtime: Option, - - /// Show available plugins from registry instead of installed - #[arg(long)] - available: bool, - - /// Force refresh of registry cache - #[arg(long)] - refresh: bool, - - /// Output in JSON format - #[arg(long)] - json: bool, - }, - - /// Search for plugins in the registry - Search { - /// Search query (matches name, description, tags) - query: String, - - /// Force refresh of registry cache - #[arg(long)] - refresh: bool, - - /// Output in JSON format - #[arg(long)] - json: bool, - }, - - /// Show detailed information about a plugin - Info { - /// Plugin ID - name: String, - - /// Show info from registry instead of installed plugin - #[arg(long)] - registry: bool, - - /// Output in JSON format - #[arg(long)] - json: bool, - }, - - /// Install a plugin from registry, path, or URL - Install { - /// Plugin source (registry name, local path, or git URL) - source: String, - - /// Force reinstall even if already installed - #[arg(long, short = 'f')] - force: bool, - }, - - /// Remove an installed plugin - Remove { - /// Plugin ID to remove - name: String, - - /// Don't ask for confirmation - #[arg(long, short = 'y')] - yes: bool, - }, - - /// Update installed plugins - Update { - /// Specific plugin to update (all if not specified) - name: Option, - }, - - /// Enable a disabled plugin - Enable { - /// Plugin ID to enable - name: String, - }, - - /// Disable an installed plugin - Disable { - /// Plugin ID to disable - name: String, - }, - - /// Create a new plugin from template - Create { - /// Plugin ID (directory name) - name: String, - - /// Runtime type to use (default: lua) - #[arg(long, short = 'r', value_enum, default_value = "lua")] - runtime: PluginRuntime, - - /// Target directory (default: current directory) - #[arg(long, short = 'd')] - dir: Option, - - /// Plugin display name - #[arg(long)] - display_name: Option, - - /// Plugin description - #[arg(long)] - description: Option, - }, - - /// Validate a plugin's structure and manifest - Validate { - /// Path to plugin directory (default: current directory) - path: Option, - }, - - /// Show available script runtimes - Runtimes, - - /// Run a plugin command - /// - /// Plugins can provide CLI commands that are invoked via: - /// owlry plugin run [args...] - /// - /// Example: - /// owlry plugin run bookmark add https://example.com "My Bookmark" - Run { - /// Plugin ID - plugin_id: String, - - /// Command to run - command: String, - - /// Arguments to pass to the command - #[arg(trailing_var_arg = true)] - args: Vec, - }, - - /// List commands provided by a plugin - Commands { - /// Plugin ID (optional - lists all if not specified) - plugin_id: Option, - }, } fn parse_provider(s: &str) -> Result { diff --git a/crates/owlry/src/client.rs b/crates/owlry/src/client.rs index 38e88ef..898ba6f 100644 --- a/crates/owlry/src/client.rs +++ b/crates/owlry/src/client.rs @@ -3,7 +3,7 @@ use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::time::Duration; -use owlry_core::ipc::{PluginEntry, ProviderDesc, Request, Response, ResultItem}; +use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem}; /// Maximum allowed size for a single IPC response line (4 MiB). /// Larger than the request limit because responses carry result sets. @@ -196,19 +196,6 @@ impl CoreClient { } } - /// Query the daemon's native plugin registry (loaded + suppressed entries). - pub fn plugin_list(&mut self) -> io::Result> { - self.send(&Request::PluginList)?; - match self.receive()? { - Response::PluginList { entries } => Ok(entries), - Response::Error { message } => Err(io::Error::other(message)), - other => Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("unexpected response to PluginList: {other:?}"), - )), - } - } - /// Query a plugin's submenu actions. pub fn submenu(&mut self, plugin_id: &str, data: &str) -> io::Result> { self.send(&Request::Submenu { diff --git a/crates/owlry/src/main.rs b/crates/owlry/src/main.rs index 1e54301..d3e501f 100644 --- a/crates/owlry/src/main.rs +++ b/crates/owlry/src/main.rs @@ -2,13 +2,12 @@ mod app; mod backend; mod cli; pub mod client; -mod plugin_commands; mod providers; mod theme; mod ui; use app::OwlryApp; -use cli::{CliArgs, Command}; +use cli::CliArgs; use log::{info, warn}; use std::os::unix::io::AsRawFd; @@ -50,17 +49,30 @@ fn try_acquire_lock() -> Option { fn main() { let args = CliArgs::parse_args(); - // Handle subcommands before initializing the full app - if let Some(command) = &args.command { - // CLI commands don't need full logging - match command { - Command::Plugin(plugin_cmd) => { - if let Err(e) = plugin_commands::execute(plugin_cmd.clone()) { - eprintln!("Error: {}", e); + // -d / --daemon: run the daemon in-process and exit when it stops. + if args.daemon { + let default_level = if cfg!(feature = "dev-logging") { "debug" } else { "info" }; + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_level)) + .format_timestamp_millis() + .init(); + + let sock = owlry_core::paths::socket_path(); + if let Err(e) = owlry_core::paths::ensure_parent_dir(&sock) { + eprintln!("Failed to create socket directory: {e}"); + std::process::exit(1); + } + match owlry_core::server::Server::bind(&sock) { + Ok(server) => { + if let Err(e) = server.run() { + eprintln!("Server error: {e}"); std::process::exit(1); } std::process::exit(0); } + Err(e) => { + eprintln!("Failed to start daemon: {e}"); + std::process::exit(1); + } } } diff --git a/crates/owlry/src/plugin_commands.rs b/crates/owlry/src/plugin_commands.rs deleted file mode 100644 index bb244e2..0000000 --- a/crates/owlry/src/plugin_commands.rs +++ /dev/null @@ -1,1296 +0,0 @@ -//! Plugin CLI command implementations -//! -//! This module provides handlers for the `owlry plugin` subcommands. - -use std::fs; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; - -use crate::cli::{PluginCommand as CliPluginCommand, PluginRuntime}; -use crate::client::CoreClient; -use owlry_core::config::Config; -use owlry_core::paths; -use owlry_core::plugins::manifest::{PluginManifest, discover_plugins}; -use owlry_core::plugins::registry::{self, RegistryClient}; -use owlry_core::plugins::runtime_loader::{lua_runtime_available, rune_runtime_available}; - -/// Result type for plugin commands -pub type CommandResult = Result<(), String>; - -/// Get registry client with configured URL -fn get_registry_client() -> RegistryClient { - let config = Config::load().unwrap_or_default(); - match &config.plugins.registry_url { - Some(url) => RegistryClient::new(url), - None => RegistryClient::default_registry(), - } -} - -/// Check if a runtime is available -fn check_runtime_available(runtime: PluginRuntime) -> CommandResult { - match runtime { - PluginRuntime::Lua if !lua_runtime_available() => { - Err("Lua runtime not installed. Install the owlry-lua package.".to_string()) - } - PluginRuntime::Rune if !rune_runtime_available() => { - Err("Rune runtime not installed. Install the owlry-rune package.".to_string()) - } - _ => Ok(()), - } -} - -/// Check if any script runtime is available -fn any_runtime_available() -> bool { - lua_runtime_available() || rune_runtime_available() -} - -/// Execute a plugin command -pub fn execute(cmd: CliPluginCommand) -> CommandResult { - match cmd { - CliPluginCommand::List { - enabled, - disabled, - runtime, - available, - refresh, - json, - } => { - if available { - cmd_list_available(refresh, json) - } else { - cmd_list_installed(enabled, disabled, runtime, json) - } - } - CliPluginCommand::Search { - query, - refresh, - json, - } => cmd_search(&query, refresh, json), - CliPluginCommand::Info { - name, - registry, - json, - } => { - if registry { - cmd_info_registry(&name, json) - } else { - cmd_info_installed(&name, json) - } - } - CliPluginCommand::Install { source, force } => { - if !any_runtime_available() { - return Err( - "No script runtime installed. Install owlry-lua or owlry-rune to use plugins." - .to_string(), - ); - } - cmd_install(&source, force) - } - CliPluginCommand::Remove { name, yes } => cmd_remove(&name, yes), - CliPluginCommand::Update { name } => cmd_update(name.as_deref()), - CliPluginCommand::Enable { name } => cmd_enable(&name), - CliPluginCommand::Disable { name } => cmd_disable(&name), - CliPluginCommand::Create { - name, - runtime, - dir, - display_name, - description, - } => { - check_runtime_available(runtime)?; - cmd_create( - &name, - runtime, - dir.as_deref(), - display_name.as_deref(), - description.as_deref(), - ) - } - CliPluginCommand::Validate { path } => cmd_validate(path.as_deref()), - CliPluginCommand::Runtimes => cmd_runtimes(), - CliPluginCommand::Run { - plugin_id, - command, - args, - } => cmd_run_plugin_command(&plugin_id, &command, &args), - CliPluginCommand::Commands { plugin_id } => cmd_list_commands(plugin_id.as_deref()), - } -} - -/// Detect the runtime type for a plugin based on entry point extension -fn detect_runtime(manifest: &PluginManifest) -> PluginRuntime { - let entry = &manifest.plugin.entry; - if entry.ends_with(".rn") { - PluginRuntime::Rune - } else { - // Default to Lua for .lua or unrecognized extensions - PluginRuntime::Lua - } -} - -/// List installed plugins -fn cmd_list_installed( - only_enabled: bool, - only_disabled: bool, - runtime_filter: Option, - json_output: bool, -) -> CommandResult { - let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?; - let config = Config::load().unwrap_or_default(); - let disabled_list = &config.plugins.disabled_plugins; - let lua_available = lua_runtime_available(); - let rune_available = rune_runtime_available(); - - // ── Script plugins (from filesystem) ──────────────────────────────── - let mut script_plugins: Vec<_> = if plugins_dir.exists() { - let discovered = discover_plugins(&plugins_dir).map_err(|e| e.to_string())?; - discovered - .into_iter() - .map(|(id, (manifest, _path))| { - let is_disabled = disabled_list.contains(&id); - let runtime = detect_runtime(&manifest); - (id, manifest, is_disabled, runtime) - }) - .collect() - } else { - Vec::new() - }; - - // Apply filters to script plugins - if only_enabled { - script_plugins.retain(|(_, _, is_disabled, _)| !*is_disabled); - } - if only_disabled { - script_plugins.retain(|(_, _, is_disabled, _)| *is_disabled); - } - if let Some(ref rt) = runtime_filter { - let rt_clone = *rt; - script_plugins.retain(|(_, _, _, runtime)| *runtime == rt_clone); - } - script_plugins.sort_by(|a, b| a.0.cmp(&b.0)); - - // ── Native plugins (from daemon, if running) ───────────────────────── - // Skip native plugins if a runtime filter is active (they have no script runtime). - let native_entries = if runtime_filter.is_none() { - CoreClient::connect(&CoreClient::socket_path()) - .ok() - .and_then(|mut client| client.plugin_list().ok()) - .unwrap_or_default() - } else { - Vec::new() - }; - - // ── Output ─────────────────────────────────────────────────────────── - if json_output { - let mut json_list: Vec<_> = script_plugins - .iter() - .map(|(id, manifest, is_disabled, runtime)| { - let runtime_available = match runtime { - PluginRuntime::Lua => lua_available, - PluginRuntime::Rune => rune_available, - }; - serde_json::json!({ - "id": id, - "name": manifest.plugin.name, - "version": manifest.plugin.version, - "description": manifest.plugin.description, - "enabled": !is_disabled, - "runtime": runtime.to_string(), - "runtime_available": runtime_available, - "source": "script", - }) - }) - .collect(); - for entry in &native_entries { - json_list.push(serde_json::json!({ - "id": entry.id, - "name": entry.name, - "version": entry.version, - "status": entry.status, - "status_detail": entry.status_detail, - "runtime": entry.runtime, - "providers": entry.providers, - "source": "native", - })); - } - println!("{}", serde_json::to_string_pretty(&json_list).unwrap()); - } else { - let total = script_plugins.len() + native_entries.len(); - if total == 0 { - println!("No plugins found."); - return Ok(()); - } - - if !script_plugins.is_empty() { - println!("Script plugins:\n"); - for (id, manifest, is_disabled, runtime) in &script_plugins { - let status = if *is_disabled { " (disabled)" } else { "" }; - let runtime_available = match runtime { - PluginRuntime::Lua => lua_available, - PluginRuntime::Rune => rune_available, - }; - let runtime_status = if !runtime_available { - format!(" [{} - NOT INSTALLED]", runtime) - } else { - format!(" [{}]", runtime) - }; - println!( - " {} v{}{}{}\n {}", - id, - manifest.plugin.version, - status, - runtime_status, - if manifest.plugin.description.is_empty() { - "No description" - } else { - &manifest.plugin.description - } - ); - } - } - - if !native_entries.is_empty() { - if !script_plugins.is_empty() { - println!(); - } - println!("Native plugins:\n"); - for entry in &native_entries { - let status_label = if entry.status == "suppressed" { - format!(" (suppressed: {})", entry.status_detail) - } else { - String::new() - }; - let providers_label = if entry.providers.is_empty() { - String::new() - } else { - format!(" [{}]", entry.providers.join(", ")) - }; - println!(" {} v{}{}{}", entry.id, entry.version, status_label, providers_label); - if !entry.name.is_empty() && entry.name != entry.id { - println!(" {}", entry.name); - } - } - } - - println!("\n{} plugin(s) total.", total); - } - - Ok(()) -} - -/// List available plugins from registry -fn cmd_list_available(refresh: bool, json_output: bool) -> CommandResult { - let client = get_registry_client(); - - println!("Fetching plugin list from registry..."); - let plugins = client.list_all(refresh)?; - - if json_output { - let json_list: Vec<_> = plugins - .iter() - .map(|p| { - serde_json::json!({ - "id": p.id, - "name": p.name, - "version": p.version, - "description": p.description, - "author": p.author, - "repository": p.repository, - "tags": p.tags, - }) - }) - .collect(); - println!("{}", serde_json::to_string_pretty(&json_list).unwrap()); - } else if plugins.is_empty() { - println!("No plugins available in registry."); - } else { - println!("Available plugins:\n"); - for p in &plugins { - println!( - " {} v{}\n {}", - p.id, - p.version, - if p.description.is_empty() { - "No description" - } else { - &p.description - } - ); - if !p.tags.is_empty() { - println!(" Tags: {}", p.tags.join(", ")); - } - } - println!("\n{} plugin(s) available.", plugins.len()); - println!("\nInstall with: owlry plugin install "); - } - - Ok(()) -} - -/// Search for plugins in registry -fn cmd_search(query: &str, refresh: bool, json_output: bool) -> CommandResult { - let client = get_registry_client(); - - let plugins = client.search(query, refresh)?; - - if json_output { - let json_list: Vec<_> = plugins - .iter() - .map(|p| { - serde_json::json!({ - "id": p.id, - "name": p.name, - "version": p.version, - "description": p.description, - "author": p.author, - "repository": p.repository, - "tags": p.tags, - }) - }) - .collect(); - println!("{}", serde_json::to_string_pretty(&json_list).unwrap()); - } else if plugins.is_empty() { - println!("No plugins found matching '{}'.", query); - } else { - println!("Search results for '{}':\n", query); - for p in &plugins { - println!( - " {} v{}\n {}", - p.id, - p.version, - if p.description.is_empty() { - "No description" - } else { - &p.description - } - ); - if !p.tags.is_empty() { - println!(" Tags: {}", p.tags.join(", ")); - } - } - println!("\n{} result(s) found.", plugins.len()); - println!("\nInstall with: owlry plugin install "); - } - - Ok(()) -} - -/// Show information about an installed plugin -fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult { - let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?; - let plugin_path = plugins_dir.join(name); - - if !plugin_path.exists() { - return Err(format!("Plugin '{}' not found", name)); - } - - let manifest_path = plugin_path.join("plugin.toml"); - if !manifest_path.exists() { - return Err(format!("Plugin '{}' has no manifest", name)); - } - - let manifest_content = fs::read_to_string(&manifest_path) - .map_err(|e| format!("Failed to read manifest: {}", e))?; - let manifest: PluginManifest = toml::from_str(&manifest_content) - .map_err(|e| format!("Failed to parse manifest: {}", e))?; - - let config = Config::load().unwrap_or_default(); - let is_enabled = !config.plugins.disabled_plugins.contains(&name.to_string()); - - let runtime = detect_runtime(&manifest); - let runtime_available = match runtime { - PluginRuntime::Lua => lua_runtime_available(), - PluginRuntime::Rune => rune_runtime_available(), - }; - - if json_output { - let info = serde_json::json!({ - "id": manifest.plugin.id, - "name": manifest.plugin.name, - "version": manifest.plugin.version, - "description": manifest.plugin.description, - "author": manifest.plugin.author, - "owlry_version": manifest.plugin.owlry_version, - "enabled": is_enabled, - "runtime": runtime.to_string(), - "runtime_available": runtime_available, - "path": plugin_path.display().to_string(), - "providers": manifest.providers.iter().map(|p| serde_json::json!({ - "id": p.id, - "name": p.name, - "type": p.provider_type, - "type_id": p.type_id, - "prefix": p.prefix, - "icon": p.icon, - })).collect::>(), - "provides": { - "providers": manifest.provides.providers, - "actions": manifest.provides.actions, - "themes": manifest.provides.themes, - "hooks": manifest.provides.hooks, - }, - "permissions": { - "network": manifest.permissions.network, - "filesystem": manifest.permissions.filesystem, - "run_commands": manifest.permissions.run_commands, - } - }); - println!("{}", serde_json::to_string_pretty(&info).unwrap()); - } else { - println!( - "Plugin: {} v{}", - manifest.plugin.name, manifest.plugin.version - ); - println!("ID: {}", manifest.plugin.id); - if !manifest.plugin.description.is_empty() { - println!("Description: {}", manifest.plugin.description); - } - if !manifest.plugin.author.is_empty() { - println!("Author: {}", manifest.plugin.author); - } - println!( - "Status: {}", - if is_enabled { "enabled" } else { "disabled" } - ); - println!( - "Runtime: {}{}", - runtime, - if runtime_available { - "" - } else { - " (NOT INSTALLED)" - } - ); - println!("Path: {}", plugin_path.display()); - println!(); - println!("Providers:"); - for p in &manifest.providers { - let prefix = p.prefix.as_deref().map(|s| format!(" ({})", s)).unwrap_or_default(); - println!(" {} [{}]{}", p.name, p.provider_type, prefix); - } - if !manifest.provides.providers.is_empty() { - println!(" {}", manifest.provides.providers.join(", ")); - } - if manifest.provides.actions { - println!(" Actions: yes"); - } - if !manifest.provides.themes.is_empty() { - println!(" Themes: {}", manifest.provides.themes.join(", ")); - } - if manifest.provides.hooks { - println!(" Hooks: yes"); - } - println!(); - println!("Permissions:"); - println!( - " Network: {}", - if manifest.permissions.network { - "yes" - } else { - "no" - } - ); - if !manifest.permissions.filesystem.is_empty() { - println!( - " Filesystem: {}", - manifest.permissions.filesystem.join(", ") - ); - } - if !manifest.permissions.run_commands.is_empty() { - println!( - " Commands: {}", - manifest.permissions.run_commands.join(", ") - ); - } - } - - Ok(()) -} - -/// Show information about a plugin from the registry -fn cmd_info_registry(name: &str, json_output: bool) -> CommandResult { - let client = get_registry_client(); - - let plugin = client - .find(name, false)? - .ok_or_else(|| format!("Plugin '{}' not found in registry", name))?; - - if json_output { - let info = serde_json::json!({ - "id": plugin.id, - "name": plugin.name, - "version": plugin.version, - "description": plugin.description, - "author": plugin.author, - "repository": plugin.repository, - "tags": plugin.tags, - "owlry_version": plugin.owlry_version, - "license": plugin.license, - }); - println!("{}", serde_json::to_string_pretty(&info).unwrap()); - } else { - println!("Plugin: {} v{}", plugin.name, plugin.version); - println!("ID: {}", plugin.id); - if !plugin.description.is_empty() { - println!("Description: {}", plugin.description); - } - if !plugin.author.is_empty() { - println!("Author: {}", plugin.author); - } - println!("Repository: {}", plugin.repository); - if !plugin.owlry_version.is_empty() { - println!("Requires: owlry {}", plugin.owlry_version); - } - if !plugin.license.is_empty() { - println!("License: {}", plugin.license); - } - if !plugin.tags.is_empty() { - println!("Tags: {}", plugin.tags.join(", ")); - } - println!(); - println!("Install with: owlry plugin install {}", plugin.id); - } - - Ok(()) -} - -/// Install a plugin from registry name, path, or URL -fn cmd_install(source: &str, force: bool) -> CommandResult { - let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?; - - // Ensure plugins directory exists - fs::create_dir_all(&plugins_dir) - .map_err(|e| format!("Failed to create plugins directory: {}", e))?; - - // Determine source type: URL, path, or registry name - if registry::is_url(source) { - // Git repository URL - install_from_git(source, &plugins_dir, force) - } else if registry::is_path(source) { - // Local path - let source_path = PathBuf::from(source); - install_from_path(&source_path, &plugins_dir, force) - } else { - // Try registry lookup - println!("Looking up '{}' in registry...", source); - let client = get_registry_client(); - - match client.find(source, false)? { - Some(plugin) => { - println!("Found: {} v{}", plugin.name, plugin.version); - install_from_git(&plugin.repository, &plugins_dir, force) - } - None => Err(format!( - "Plugin '{}' not found in registry. Use a local path or git URL.", - source - )), - } - } -} - -/// Install plugin from a local directory -fn install_from_path(source: &Path, plugins_dir: &Path, force: bool) -> CommandResult { - // Validate source has manifest - let manifest_path = source.join("plugin.toml"); - if !manifest_path.exists() { - return Err("Source directory does not contain plugin.toml".to_string()); - } - - let manifest_content = fs::read_to_string(&manifest_path) - .map_err(|e| format!("Failed to read manifest: {}", e))?; - let manifest: PluginManifest = toml::from_str(&manifest_content) - .map_err(|e| format!("Failed to parse manifest: {}", e))?; - - let target_dir = plugins_dir.join(&manifest.plugin.id); - - if target_dir.exists() { - if force { - println!("Removing existing plugin..."); - fs::remove_dir_all(&target_dir) - .map_err(|e| format!("Failed to remove existing plugin: {}", e))?; - } else { - return Err(format!( - "Plugin '{}' is already installed. Use --force to reinstall.", - manifest.plugin.id - )); - } - } - - // Copy plugin files - copy_dir_recursive(source, &target_dir)?; - - println!( - "Installed plugin '{}' v{} to {}", - manifest.plugin.name, - manifest.plugin.version, - target_dir.display() - ); - - Ok(()) -} - -/// Install plugin from a git repository -fn install_from_git(url: &str, plugins_dir: &Path, force: bool) -> CommandResult { - // Create temp directory for clone - let temp_dir = std::env::temp_dir().join(format!("owlry-plugin-{}", std::process::id())); - - // Clean up temp dir if it exists - if temp_dir.exists() { - fs::remove_dir_all(&temp_dir) - .map_err(|e| format!("Failed to clean temp directory: {}", e))?; - } - - println!("Cloning {}...", url); - - // Clone repository - let status = std::process::Command::new("git") - .args(["clone", "--depth=1", url, temp_dir.to_str().unwrap()]) - .status() - .map_err(|e| format!("Failed to run git: {}", e))?; - - if !status.success() { - return Err("Git clone failed".to_string()); - } - - // Install from cloned directory - let result = install_from_path(&temp_dir, plugins_dir, force); - - // Clean up temp directory - let _ = fs::remove_dir_all(&temp_dir); - - result -} - -/// Recursively copy a directory -fn copy_dir_recursive(src: &Path, dst: &Path) -> CommandResult { - fs::create_dir_all(dst) - .map_err(|e| format!("Failed to create directory {}: {}", dst.display(), e))?; - - for entry in fs::read_dir(src).map_err(|e| format!("Failed to read directory: {}", e))? { - let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - - if src_path.is_dir() { - // Skip .git directory - if src_path.file_name().is_some_and(|n| n == ".git") { - continue; - } - copy_dir_recursive(&src_path, &dst_path)?; - } else { - fs::copy(&src_path, &dst_path) - .map_err(|e| format!("Failed to copy {}: {}", src_path.display(), e))?; - } - } - - Ok(()) -} - -/// Remove an installed plugin -fn cmd_remove(name: &str, yes: bool) -> CommandResult { - let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?; - let plugin_path = plugins_dir.join(name); - - if !plugin_path.exists() { - return Err(format!("Plugin '{}' not found", name)); - } - - // Confirm unless --yes flag - if !yes { - print!("Remove plugin '{}'? [y/N] ", name); - io::stdout().flush().unwrap(); - - let mut input = String::new(); - io::stdin().read_line(&mut input).unwrap(); - - if !input.trim().eq_ignore_ascii_case("y") { - println!("Cancelled."); - return Ok(()); - } - } - - fs::remove_dir_all(&plugin_path).map_err(|e| format!("Failed to remove plugin: {}", e))?; - - // Also remove from disabled list if present - if let Ok(mut config) = Config::load() { - config.plugins.disabled_plugins.retain(|id| id != name); - if let Err(e) = config.save() { - eprintln!("Warning: Failed to update config: {}", e); - } - } - - println!("Removed plugin '{}'", name); - Ok(()) -} - -/// Update plugins -fn cmd_update(name: Option<&str>) -> CommandResult { - // For now, update is not implemented as we don't have source tracking - // A full implementation would: - // 1. Track where each plugin was installed from (git URL) - // 2. Pull updates and reinstall - if let Some(plugin) = name { - println!("Update for plugin '{}' not yet implemented.", plugin); - println!("To update manually, remove and reinstall the plugin."); - } else { - println!("Plugin updates not yet implemented."); - println!("To update a plugin, remove and reinstall it."); - } - Ok(()) -} - -/// Enable a plugin -fn cmd_enable(name: &str) -> CommandResult { - let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?; - let plugin_path = plugins_dir.join(name); - - if !plugin_path.exists() { - return Err(format!("Plugin '{}' not found", name)); - } - - let mut config = Config::load().unwrap_or_default(); - - if !config.plugins.disabled_plugins.contains(&name.to_string()) { - println!("Plugin '{}' is already enabled.", name); - return Ok(()); - } - - config.plugins.disabled_plugins.retain(|id| id != name); - config - .save() - .map_err(|e| format!("Failed to save config: {}", e))?; - - println!("Enabled plugin '{}'", name); - Ok(()) -} - -/// Disable a plugin -fn cmd_disable(name: &str) -> CommandResult { - let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?; - let plugin_path = plugins_dir.join(name); - - if !plugin_path.exists() { - return Err(format!("Plugin '{}' not found", name)); - } - - let mut config = Config::load().unwrap_or_default(); - - if config.plugins.disabled_plugins.contains(&name.to_string()) { - println!("Plugin '{}' is already disabled.", name); - return Ok(()); - } - - config.plugins.disabled_plugins.push(name.to_string()); - config - .save() - .map_err(|e| format!("Failed to save config: {}", e))?; - - println!("Disabled plugin '{}'", name); - Ok(()) -} - -/// Create a new plugin from template -fn cmd_create( - name: &str, - runtime: PluginRuntime, - dir: Option<&str>, - display_name: Option<&str>, - description: Option<&str>, -) -> CommandResult { - let base_dir = dir - .map(PathBuf::from) - .unwrap_or_else(|| std::env::current_dir().unwrap()); - let plugin_dir = base_dir.join(name); - - if plugin_dir.exists() { - return Err(format!( - "Directory '{}' already exists", - plugin_dir.display() - )); - } - - fs::create_dir_all(&plugin_dir).map_err(|e| format!("Failed to create directory: {}", e))?; - - let display = display_name.unwrap_or(name); - let desc = description.unwrap_or("A custom owlry plugin"); - - let (entry_file, entry_ext) = match runtime { - PluginRuntime::Lua => ("main.lua", "lua"), - PluginRuntime::Rune => ("main.rn", "rn"), - }; - - // Derive a short type_id from the plugin name (strip common prefixes) - let type_id = name - .strip_prefix("owlry-") - .unwrap_or(name) - .replace('-', "_"); - - // Create plugin.toml - let manifest = format!( - r#"[plugin] -id = "{name}" -name = "{display}" -version = "0.1.0" -description = "{desc}" -entry_point = "{entry_file}" - -[[providers]] -id = "{name}" -name = "{display}" -type = "static" -type_id = "{type_id}" -icon = "application-x-addon" -# prefix = ":{type_id}" -# tab_label = "{display}" -# search_noun = "{type_id} items" -"#, - name = name, - display = display, - desc = desc, - entry_file = entry_file, - type_id = type_id, - ); - - fs::write(plugin_dir.join("plugin.toml"), manifest) - .map_err(|e| format!("Failed to write manifest: {}", e))?; - - // Create entry point template based on runtime - match runtime { - PluginRuntime::Lua => { - let main_lua = format!( - r#"-- {display} Plugin for Owlry --- {desc} - -function refresh() - return {{ - {{ - id = "{name}:example", - name = "Example Item", - description = "This is an example item from {display}", - icon = "dialog-information", - command = "echo 'Hello from {name}!'", - tags = {{}}, - }}, - }} -end -"#, - name = name, - display = display, - desc = desc, - ); - fs::write(plugin_dir.join(entry_file), main_lua) - .map_err(|e| format!("Failed to write {}: {}", entry_file, e))?; - } - PluginRuntime::Rune => { - let main_rn = format!( - r#"use owlry::Item; - -pub fn refresh() {{ - let items = []; - - items.push( - Item::new("{name}:example", "Example Item", "echo 'Hello from {name}!'") - .description("This is an example item from {display}") - .icon("dialog-information") - .keywords(["example"]), - ); - - items -}} -"#, - name = name, - display = display, - ); - fs::write(plugin_dir.join(entry_file), main_rn) - .map_err(|e| format!("Failed to write {}: {}", entry_file, e))?; - } - } - - println!( - "Created {} plugin '{}' at {}", - runtime, - name, - plugin_dir.display() - ); - println!(); - println!("Next steps:"); - println!( - " 1. Edit {}/{} to implement your provider", - name, entry_file - ); - println!( - " 2. Install: owlry plugin install {}", - plugin_dir.display() - ); - println!(" 3. Test: owlry (your plugin items should appear)"); - println!(); - println!( - "Runtime: {} (requires owlry-{} package)", - runtime, entry_ext - ); - - Ok(()) -} - -/// Validate a plugin's structure -fn cmd_validate(path: Option<&str>) -> CommandResult { - let plugin_path = path - .map(PathBuf::from) - .unwrap_or_else(|| std::env::current_dir().unwrap()); - - println!("Validating plugin at {}...", plugin_path.display()); - - let mut errors: Vec = Vec::new(); - let mut warnings: Vec = Vec::new(); - - // Check manifest exists - let manifest_path = plugin_path.join("plugin.toml"); - if !manifest_path.exists() { - errors.push("Missing plugin.toml manifest".to_string()); - } else { - // Parse and validate manifest - match fs::read_to_string(&manifest_path) { - Ok(content) => match toml::from_str::(&content) { - Ok(manifest) => { - // Check required fields - if manifest.plugin.id.is_empty() { - errors.push("plugin.id is empty".to_string()); - } - if manifest.plugin.name.is_empty() { - errors.push("plugin.name is empty".to_string()); - } - if manifest.plugin.version.is_empty() { - errors.push("plugin.version is empty".to_string()); - } - - // Check entry point - let entry = &manifest.plugin.entry; - let entry_path = plugin_path.join(entry); - if !entry_path.exists() { - errors.push(format!("Entry point '{}' not found", entry)); - } - - // Validate owlry_version semver - if manifest.plugin.owlry_version.is_empty() { - warnings.push("No owlry_version specified".to_string()); - } else if semver::VersionReq::parse(&manifest.plugin.owlry_version).is_err() { - errors.push(format!( - "Invalid owlry_version requirement: {}", - manifest.plugin.owlry_version - )); - } - - // Check for empty provides (accept either [[providers]] or [provides]) - let has_providers = !manifest.providers.is_empty() - || !manifest.provides.providers.is_empty() - || manifest.provides.actions - || !manifest.provides.themes.is_empty() - || manifest.provides.hooks; - if !has_providers { - warnings.push("Plugin does not declare any providers".to_string()); - } - - println!(" Plugin ID: {}", manifest.plugin.id); - println!(" Version: {}", manifest.plugin.version); - } - Err(e) => { - errors.push(format!("Failed to parse manifest: {}", e)); - } - }, - Err(e) => { - errors.push(format!("Failed to read manifest: {}", e)); - } - } - } - - // Report results - println!(); - - if !warnings.is_empty() { - println!("Warnings:"); - for w in &warnings { - println!(" ⚠ {}", w); - } - println!(); - } - - if errors.is_empty() { - println!("✓ Plugin is valid"); - Ok(()) - } else { - println!("Errors:"); - for e in &errors { - println!(" ✗ {}", e); - } - Err(format!("{} error(s) found", errors.len())) - } -} - -/// Show available script runtimes -fn cmd_runtimes() -> CommandResult { - use owlry_core::plugins::runtime_loader::SYSTEM_RUNTIMES_DIR; - - println!("Script Runtimes:\n"); - - let lua_available = lua_runtime_available(); - let rune_available = rune_runtime_available(); - - // Lua runtime - if lua_available { - println!(" ✓ Lua - Installed"); - println!(" Package: owlry-lua"); - println!(" Entry point: main.lua"); - } else { - println!(" ✗ Lua - Not installed"); - println!(" Install: yay -S owlry-lua"); - println!(" Entry point: main.lua"); - } - - println!(); - - // Rune runtime - if rune_available { - println!(" ✓ Rune - Installed"); - println!(" Package: owlry-rune"); - println!(" Entry point: main.rn"); - } else { - println!(" ✗ Rune - Not installed"); - println!(" Install: yay -S owlry-rune"); - println!(" Entry point: main.rn"); - } - - println!(); - println!("Runtime directory: {}", SYSTEM_RUNTIMES_DIR); - - if !lua_available && !rune_available { - println!(); - println!("No runtimes installed. Install at least one to use plugins:"); - println!(" yay -S owlry-lua # For Lua plugins"); - println!(" yay -S owlry-rune # For Rune plugins"); - } - - Ok(()) -} - -/// Run a plugin command -fn cmd_run_plugin_command(plugin_id: &str, command: &str, args: &[String]) -> CommandResult { - let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?; - let plugin_path = plugins_dir.join(plugin_id); - - if !plugin_path.exists() { - return Err(format!("Plugin '{}' not found", plugin_id)); - } - - let manifest_path = plugin_path.join("plugin.toml"); - if !manifest_path.exists() { - return Err(format!("Plugin '{}' has no manifest", plugin_id)); - } - - let manifest_content = fs::read_to_string(&manifest_path) - .map_err(|e| format!("Failed to read manifest: {}", e))?; - let manifest: PluginManifest = toml::from_str(&manifest_content) - .map_err(|e| format!("Failed to parse manifest: {}", e))?; - - // Check if plugin provides this command - let cmd_info = manifest - .provides - .commands - .iter() - .find(|c| c.name == command); - if cmd_info.is_none() { - let available: Vec<_> = manifest - .provides - .commands - .iter() - .map(|c| c.name.as_str()) - .collect(); - if available.is_empty() { - return Err(format!( - "Plugin '{}' does not provide any CLI commands", - plugin_id - )); - } - return Err(format!( - "Plugin '{}' does not have command '{}'. Available: {}", - plugin_id, - command, - available.join(", ") - )); - } - - // Check runtime availability - let runtime = detect_runtime(&manifest); - check_runtime_available(runtime)?; - - // Execute the command via the plugin runtime - // The runtime will call the plugin's command handler - execute_plugin_command(&plugin_path, &manifest, command, args) -} - -/// Execute a plugin command through the runtime -fn execute_plugin_command( - plugin_path: &Path, - manifest: &PluginManifest, - command: &str, - args: &[String], -) -> CommandResult { - use owlry_core::plugins::runtime_loader::{LoadedRuntime, SYSTEM_RUNTIMES_DIR}; - - let runtime = detect_runtime(manifest); - - // Load the appropriate runtime - let loaded_runtime = match runtime { - PluginRuntime::Lua => { - let owlry_version = env!("CARGO_PKG_VERSION"); - LoadedRuntime::load_lua(plugin_path.parent().unwrap_or(plugin_path), owlry_version) - .map_err(|e| format!("Failed to load Lua runtime: {}", e))? - } - PluginRuntime::Rune => { - let owlry_version = env!("CARGO_PKG_VERSION"); - LoadedRuntime::load_rune(plugin_path.parent().unwrap_or(plugin_path), owlry_version) - .map_err(|e| format!("Failed to load Rune runtime: {}", e))? - } - }; - - // Build the command query string - // Format: !CMD::::... - let mut query_parts = vec!["!CMD".to_string(), command.to_string()]; - query_parts.extend(args.iter().cloned()); - let _query = query_parts.join(":"); - - // Find the provider from this plugin and send the command query - let _provider_name = manifest - .provides - .providers - .first() - .ok_or_else(|| format!("Plugin '{}' has no providers", manifest.plugin.id))?; - - // Query the provider with the command - // The runtime will interpret !CMD: prefix as a command invocation - let _providers = loaded_runtime.providers(); - - // For now, we use a simpler approach: invoke the entry point with command args - // This requires runtime support for command execution - println!( - "Executing: owlry plugin run {} {} {}", - manifest.plugin.id, - command, - args.join(" ") - ); - println!(); - println!("Note: Plugin command execution requires runtime support."); - println!("The plugin entry point should handle CLI commands via owlry.command.register()"); - println!(); - println!( - "Runtime: {} ({})", - runtime, - if PathBuf::from(SYSTEM_RUNTIMES_DIR) - .join(match runtime { - PluginRuntime::Lua => "liblua.so", - PluginRuntime::Rune => "librune.so", - }) - .exists() - { - "available" - } else { - "NOT INSTALLED" - } - ); - - // TODO: Implement actual command execution through runtime - // This would involve: - // 1. Loading the plugin in the runtime - // 2. Calling the registered command handler - // 3. Capturing and displaying output - - Ok(()) -} - -/// List commands provided by plugins -fn cmd_list_commands(plugin_id: Option<&str>) -> CommandResult { - let plugins_dir = paths::plugins_dir().ok_or("Could not determine plugins directory")?; - - if !plugins_dir.exists() { - println!("No plugins installed."); - return Ok(()); - } - - let discovered = discover_plugins(&plugins_dir).map_err(|e| e.to_string())?; - - if let Some(id) = plugin_id { - // Show commands for a specific plugin - let (manifest, _path) = discovered - .get(id) - .ok_or_else(|| format!("Plugin '{}' not found", id))?; - - if manifest.provides.commands.is_empty() { - println!("Plugin '{}' does not provide any CLI commands.", id); - return Ok(()); - } - - println!("Commands provided by '{}':\n", id); - for cmd in &manifest.provides.commands { - let usage = if cmd.usage.is_empty() { - String::new() - } else { - format!(" {}", cmd.usage) - }; - println!(" owlry plugin run {} {}{}", id, cmd.name, usage); - if !cmd.description.is_empty() { - println!(" {}", cmd.description); - } - } - } else { - // Show all plugin commands - let mut found_any = false; - - for (id, (manifest, _path)) in &discovered { - if manifest.provides.commands.is_empty() { - continue; - } - - if !found_any { - println!("Plugin CLI Commands:\n"); - found_any = true; - } - - let runtime = detect_runtime(manifest); - let runtime_available = match runtime { - PluginRuntime::Lua => lua_runtime_available(), - PluginRuntime::Rune => rune_runtime_available(), - }; - let runtime_status = if !runtime_available { - format!(" [{}]", runtime) - } else { - String::new() - }; - - println!(" {} v{}{}", id, manifest.plugin.version, runtime_status); - for cmd in &manifest.provides.commands { - let usage = if cmd.usage.is_empty() { - String::new() - } else { - format!(" {}", cmd.usage) - }; - println!(" {} {}{}", id, cmd.name, usage); - if !cmd.description.is_empty() { - println!(" {}", cmd.description); - } - } - println!(); - } - - if !found_any { - println!("No plugins provide CLI commands."); - println!(); - println!("Plugins can declare commands in plugin.toml:"); - println!(); - println!(" [[provides.commands]]"); - println!(" name = \"sync\""); - println!(" description = \"Sync data from remote\""); - println!(" usage = \"[--force]\""); - } - } - - Ok(()) -} diff --git a/crates/owlry/src/ui/submenu.rs b/crates/owlry/src/ui/submenu.rs index 3325c77..7a809ca 100644 --- a/crates/owlry/src/ui/submenu.rs +++ b/crates/owlry/src/ui/submenu.rs @@ -94,7 +94,7 @@ mod tests { command: "SUBMENU:plugin:data".to_string(), terminal: false, tags: vec![], - source: ItemSource::NativePlugin, + source: ItemSource::Core, }; assert!(is_submenu_item(&submenu_item)); @@ -107,7 +107,7 @@ mod tests { command: "some-command".to_string(), terminal: false, tags: vec![], - source: ItemSource::NativePlugin, + source: ItemSource::Core, }; assert!(!is_submenu_item(&normal_item)); } diff --git a/docs/RESTRUCTURE-V2.md b/docs/RESTRUCTURE-V2.md index aa8e738..77e5537 100644 --- a/docs/RESTRUCTURE-V2.md +++ b/docs/RESTRUCTURE-V2.md @@ -447,9 +447,17 @@ Phase 1 is "done" when all of these pass: This section captures in-progress state. Update freely as work proceeds. - **Branch:** `v2`, cut from `main @ 1caa050` on 2026-05-13 -- **Current task:** #3 (Delete C-ABI plugin system) — in progress +- **Checkpoints landed:** + - `163e68a` — plan doc + - `2fc976b` — D15–D21 resolutions + - (next) — C-ABI demolition: tasks #3/#4/#5 done in one commit +- **Tasks done:** #1 inventory, #3 delete C-ABI, #4 delete Rune+Lua crates, #5 delete config_editor (scripts never lived in this repo) +- **Tasks remaining (Phase 1):** #2 workspace collapse, #6 convert 8 plugins, #7 cargo features, #8 sys→power rename, #9 CLI subcommands, #10 auto-mode test, #11 final build+smoke - **Stray processes from inventory phase:** - PIDs 3042, 3278 — pre-existing `owlryd` (double-spawn bug; will resolve via Phase 5) - PID 594897 — test daemon from inventory probe; harness denied kill; resolves at next reboot or user kill - **Hyprland exec-once for owlryd:** suspected source of double-spawn; verify and remove during Phase 5 -- **`plugin-api-v1.0.1` git tag:** still has `API_VERSION = 3`. Moot once C-ABI is deleted. +- **`plugin-api-v1.0.1` git tag:** still has `API_VERSION = 3`. Moot now — C-ABI deleted. +- **Known clippy nits (Phase 5 cleanup):** ~8 `sort_by` → `sort_by_key` suggestions in `providers/mod.rs` and currency provider. Style only, not blocking. +- **`refresh_widgets()` stub in `backend.rs`:** retained as a no-op since the UI still calls it. Delete when widgets are revisited (post-2.0 per D20). +- **`system` provider rename to `power` (D13):** pending Task #8. Internal type_id and config key still `system`. Test names still use `sys_plugin`. From 1d20754b6677f5aeb1c0348a130759670948297b Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 02:00:37 +0200 Subject: [PATCH 04/23] test(v2): characterize demolition behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD cleanup pass for the C-ABI removal — pin down every behavior change so subsequent phases can't silently regress. owlry-core lib (providers/mod.rs, +14 tests): - Provider trait default methods return documented values - ProviderPosition::as_str matches IPC strings - ProviderType::FromStr accepts plural aliases (apps/cmds) - ItemSource::FromStr maps unknown (including legacy 'native_plugin') to Core - ItemSource::as_str emits only supported variants - add_provider refreshes and appends - available_providers uses trait overrides for Plugin(id) - query_submenu_actions: matches+actions / no match / empty actions - execute_plugin_action: static handles / dynamic handles / nothing handles - new with no providers does not panic owlry-core integration (tests/ipc_test.rs, +2 tests): - plugin_list Request must not deserialize (loud failure for old clients) - plugin_list Response must not deserialize (clients can't accept stale daemon replies) owlry CLI (src/cli.rs, +6 tests): - no args yields UI launch defaults - -d / --daemon enable daemon mode - daemon flag conflicts with -m / --profile / -p - mode flag parses known providers and unknown -> Plugin(id) - plugin subcommand tree is no longer recognised 178 tests total (up from 142 baseline). --- crates/owlry-core/src/providers/mod.rs | 281 +++++++++++++++++++++++++ crates/owlry-core/tests/ipc_test.rs | 19 ++ crates/owlry/src/cli.rs | 60 ++++++ docs/RESTRUCTURE-V2.md | 3 +- 4 files changed, 362 insertions(+), 1 deletion(-) diff --git a/crates/owlry-core/src/providers/mod.rs b/crates/owlry-core/src/providers/mod.rs index 3813e55..7e6c75e 100644 --- a/crates/owlry-core/src/providers/mod.rs +++ b/crates/owlry-core/src/providers/mod.rs @@ -812,4 +812,285 @@ mod tests { assert_eq!(results.len(), 1); assert_eq!(results[0].0.name, "Firefox"); } + + // ========================================================================= + // Tests for behavior introduced in the v2 C-ABI demolition (commit ae4a903) + // ========================================================================= + + /// Provider impl that overrides every trait method to verify customization + /// flows through `ProviderManager::available_providers()` and submenu/action + /// dispatch. + struct RichMockProvider { + type_id: String, + items: Vec, + submenu: Vec, + action_handled_for: Option, + } + + impl Provider for RichMockProvider { + fn name(&self) -> &str { + "Rich" + } + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(self.type_id.clone()) + } + fn refresh(&mut self) {} + fn items(&self) -> &[LaunchItem] { + &self.items + } + fn prefix(&self) -> Option<&str> { + Some(":rich") + } + fn icon(&self) -> &str { + "rich-icon" + } + fn position(&self) -> ProviderPosition { + ProviderPosition::Widget + } + fn priority(&self) -> u32 { + 42 + } + fn tab_label(&self) -> Option<&str> { + Some("Rich") + } + fn search_noun(&self) -> Option<&str> { + Some("rich things") + } + fn submenu_actions(&self, _data: &str) -> Vec { + self.submenu.clone() + } + fn execute_action(&self, command: &str) -> bool { + self.action_handled_for + .as_ref() + .map(|prefix| command.starts_with(prefix.as_str())) + .unwrap_or(false) + } + } + + #[test] + fn provider_trait_default_methods_return_documented_values() { + // MockProvider does not override any optional method; defaults must hold. + let p = MockProvider::new("M", ProviderType::Application); + assert_eq!(p.prefix(), None); + assert_eq!(p.icon(), "application-x-addon"); + assert_eq!(p.position(), ProviderPosition::Normal); + assert_eq!(p.priority(), 0); + assert_eq!(p.tab_label(), None); + assert_eq!(p.search_noun(), None); + assert!(p.submenu_actions("anything").is_empty()); + assert!(!p.execute_action("MOCK:anything")); + } + + #[test] + fn provider_position_as_str_matches_ipc_strings() { + assert_eq!(ProviderPosition::Normal.as_str(), "normal"); + assert_eq!(ProviderPosition::Widget.as_str(), "widget"); + } + + #[test] + fn provider_type_from_str_accepts_plural_aliases() { + // After the demolition, FromStr accepts both singular and plural aliases. + use std::str::FromStr; + assert_eq!(ProviderType::from_str("app").unwrap(), ProviderType::Application); + assert_eq!(ProviderType::from_str("apps").unwrap(), ProviderType::Application); + assert_eq!( + ProviderType::from_str("application").unwrap(), + ProviderType::Application + ); + assert_eq!( + ProviderType::from_str("applications").unwrap(), + ProviderType::Application + ); + assert_eq!(ProviderType::from_str("cmd").unwrap(), ProviderType::Command); + assert_eq!(ProviderType::from_str("cmds").unwrap(), ProviderType::Command); + assert_eq!( + ProviderType::from_str("command").unwrap(), + ProviderType::Command + ); + assert_eq!( + ProviderType::from_str("commands").unwrap(), + ProviderType::Command + ); + assert_eq!(ProviderType::from_str("dmenu").unwrap(), ProviderType::Dmenu); + // Anything unknown becomes Plugin(s) — preserves user-defined provider IDs. + assert_eq!( + ProviderType::from_str("bookmarks").unwrap(), + ProviderType::Plugin("bookmarks".into()) + ); + assert_eq!( + ProviderType::from_str("uuctl").unwrap(), + ProviderType::Plugin("uuctl".into()) + ); + } + + #[test] + fn item_source_from_str_maps_unknown_to_core() { + // NativePlugin variant is gone; unknown strings (including the old + // "native_plugin" tag from pre-2.0 daemons) decode to Core. + use std::str::FromStr; + assert_eq!(ItemSource::from_str("core"), Ok(ItemSource::Core)); + assert_eq!( + ItemSource::from_str("script_plugin"), + Ok(ItemSource::ScriptPlugin) + ); + assert_eq!(ItemSource::from_str("native_plugin"), Ok(ItemSource::Core)); + assert_eq!(ItemSource::from_str(""), Ok(ItemSource::Core)); + assert_eq!(ItemSource::from_str("anything-else"), Ok(ItemSource::Core)); + } + + #[test] + fn item_source_as_str_only_emits_supported_variants() { + assert_eq!(ItemSource::Core.as_str(), "core"); + assert_eq!(ItemSource::ScriptPlugin.as_str(), "script_plugin"); + } + + #[test] + fn add_provider_refreshes_and_appends() { + let mut pm = ProviderManager::new(Vec::new(), Vec::new()); + assert_eq!(pm.available_provider_types().len(), 0); + + let prov = MockProvider::new("Late", ProviderType::Plugin("late".into())); + pm.add_provider(Box::new(prov)); + + let types = pm.available_provider_types(); + assert_eq!(types.len(), 1); + assert_eq!(types[0], ProviderType::Plugin("late".into())); + // add_provider must call refresh() — checked indirectly by the impl + // contract; refresh_count on MockProvider was bumped, but we can't + // peek through the Box. The public observable is that items() + // is callable without panic, which we exercise here. + assert!(pm.available_providers()[0].id == "late"); + } + + #[test] + fn available_providers_uses_trait_overrides_for_plugin_type() { + let prov = RichMockProvider { + type_id: "rich".into(), + items: Vec::new(), + submenu: Vec::new(), + action_handled_for: None, + }; + let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); + let descs = pm.available_providers(); + assert_eq!(descs.len(), 1); + let d = &descs[0]; + assert_eq!(d.id, "rich"); + assert_eq!(d.prefix.as_deref(), Some(":rich")); + assert_eq!(d.icon, "rich-icon"); + assert_eq!(d.position, "widget"); + assert_eq!(d.tab_label.as_deref(), Some("Rich")); + assert_eq!(d.search_noun.as_deref(), Some("rich things")); + } + + #[test] + fn query_submenu_actions_returns_some_when_provider_matches_and_has_actions() { + let prov = RichMockProvider { + type_id: "uuctl".into(), + items: Vec::new(), + submenu: vec![make_item( + "start", + "Start service", + ProviderType::Plugin("uuctl".into()), + )], + action_handled_for: None, + }; + let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); + + let result = pm.query_submenu_actions("uuctl", "foo.service:true", "foo"); + assert!(result.is_some(), "expected Some when provider matches and returns actions"); + let (display, actions) = result.unwrap(); + assert_eq!(display, "foo"); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].name, "Start service"); + } + + #[test] + fn query_submenu_actions_returns_none_when_no_provider_matches() { + let prov = RichMockProvider { + type_id: "uuctl".into(), + items: Vec::new(), + submenu: vec![make_item("x", "x", ProviderType::Plugin("uuctl".into()))], + action_handled_for: None, + }; + let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); + assert!(pm.query_submenu_actions("does-not-exist", "data", "name").is_none()); + } + + #[test] + fn query_submenu_actions_returns_none_when_provider_returns_empty_actions() { + let prov = RichMockProvider { + type_id: "uuctl".into(), + items: Vec::new(), + submenu: Vec::new(), // matching provider but no actions + action_handled_for: None, + }; + let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); + assert!(pm.query_submenu_actions("uuctl", "data", "name").is_none()); + } + + #[test] + fn execute_plugin_action_returns_true_when_static_provider_handles() { + let prov = RichMockProvider { + type_id: "pomodoro".into(), + items: Vec::new(), + submenu: Vec::new(), + action_handled_for: Some("POMODORO:".into()), + }; + let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); + assert!(pm.execute_plugin_action("POMODORO:start")); + } + + #[test] + fn execute_plugin_action_returns_true_when_dynamic_provider_handles() { + struct DynStub { + handles: String, + } + impl DynamicProvider for DynStub { + fn name(&self) -> &str { + "dyn" + } + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin("dyn".into()) + } + fn query(&self, _q: &str) -> Vec { + Vec::new() + } + fn priority(&self) -> u32 { + 0 + } + fn execute_action(&self, command: &str) -> bool { + command.starts_with(&self.handles) + } + } + + let pm = ProviderManager::new( + Vec::new(), + vec![Box::new(DynStub { + handles: "DYN:".into(), + })], + ); + assert!(pm.execute_plugin_action("DYN:thing")); + } + + #[test] + fn execute_plugin_action_returns_false_when_nothing_handles() { + let prov = RichMockProvider { + type_id: "x".into(), + items: Vec::new(), + submenu: Vec::new(), + action_handled_for: Some("X:".into()), + }; + let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); + assert!(!pm.execute_plugin_action("UNRELATED:command")); + } + + #[test] + fn provider_manager_new_with_no_providers_does_not_panic() { + // Regression guard: the daemon may be configured to disable every + // provider; construction must still succeed. + let pm = ProviderManager::new(Vec::new(), Vec::new()); + assert_eq!(pm.available_providers().len(), 0); + assert!(!pm.execute_plugin_action("ANYTHING:foo")); + assert!(pm.query_submenu_actions("anything", "data", "n").is_none()); + } } diff --git a/crates/owlry-core/tests/ipc_test.rs b/crates/owlry-core/tests/ipc_test.rs index 0e88b54..ce3c62b 100644 --- a/crates/owlry-core/tests/ipc_test.rs +++ b/crates/owlry-core/tests/ipc_test.rs @@ -131,6 +131,25 @@ fn test_terminal_field_defaults_false() { assert!(!item.terminal); } +#[test] +fn test_plugin_list_request_is_rejected() { + // v2 dropped Request::PluginList. The daemon must reject incoming + // `{"type":"plugin_list"}` requests so old clients fail loudly instead + // of silently appearing to work. + let result: Result = serde_json::from_str(r#"{"type":"plugin_list"}"#); + assert!(result.is_err(), "plugin_list request must not deserialize"); +} + +#[test] +fn test_plugin_list_response_is_rejected() { + // Same on the response side — old daemons replying with + // `{"type":"plugin_list", ...}` must fail to parse so clients can't + // accidentally treat them as valid. + let result: Result = + serde_json::from_str(r#"{"type":"plugin_list","entries":[]}"#); + assert!(result.is_err(), "plugin_list response must not deserialize"); +} + #[test] fn test_terminal_field_roundtrip() { let item = ResultItem { diff --git a/crates/owlry/src/cli.rs b/crates/owlry/src/cli.rs index 107adb6..1fa4640 100644 --- a/crates/owlry/src/cli.rs +++ b/crates/owlry/src/cli.rs @@ -82,3 +82,63 @@ impl CliArgs { Self::parse() } } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn no_args_yields_ui_launch_defaults() { + let args = CliArgs::try_parse_from(["owlry"]).unwrap(); + assert!(!args.daemon); + assert!(args.mode.is_none()); + assert!(args.profile.is_none()); + assert!(args.prompt.is_none()); + } + + #[test] + fn dash_d_enables_daemon_mode() { + let args = CliArgs::try_parse_from(["owlry", "-d"]).unwrap(); + assert!(args.daemon); + } + + #[test] + fn long_daemon_flag_enables_daemon_mode() { + let args = CliArgs::try_parse_from(["owlry", "--daemon"]).unwrap(); + assert!(args.daemon); + } + + #[test] + fn daemon_flag_conflicts_with_ui_flags() { + // -d must reject UI-only flags so a single invocation can't try to + // both run the daemon and launch a UI in single-mode. + assert!(CliArgs::try_parse_from(["owlry", "-d", "-m", "app"]).is_err()); + assert!(CliArgs::try_parse_from(["owlry", "-d", "--profile", "dev"]).is_err()); + assert!(CliArgs::try_parse_from(["owlry", "-d", "-p", "prompt"]).is_err()); + } + + #[test] + fn mode_flag_parses_known_providers() { + let args = CliArgs::try_parse_from(["owlry", "-m", "app"]).unwrap(); + assert_eq!(args.mode, Some(ProviderType::Application)); + + let args = CliArgs::try_parse_from(["owlry", "-m", "cmd"]).unwrap(); + assert_eq!(args.mode, Some(ProviderType::Command)); + + let args = CliArgs::try_parse_from(["owlry", "-m", "dmenu"]).unwrap(); + assert_eq!(args.mode, Some(ProviderType::Dmenu)); + + // Unknown modes become Plugin(s) so user-defined providers (Phase 3+) + // and built-in provider IDs (uuctl, bookmarks, etc.) work uniformly. + let args = CliArgs::try_parse_from(["owlry", "-m", "uuctl"]).unwrap(); + assert_eq!(args.mode, Some(ProviderType::Plugin("uuctl".into()))); + } + + #[test] + fn plugin_subcommand_is_no_longer_recognised() { + // The entire `plugin` subcommand tree was dropped in v2. + assert!(CliArgs::try_parse_from(["owlry", "plugin", "list"]).is_err()); + assert!(CliArgs::try_parse_from(["owlry", "plugin", "install", "x"]).is_err()); + } +} diff --git a/docs/RESTRUCTURE-V2.md b/docs/RESTRUCTURE-V2.md index 77e5537..8d74ddd 100644 --- a/docs/RESTRUCTURE-V2.md +++ b/docs/RESTRUCTURE-V2.md @@ -450,7 +450,8 @@ This section captures in-progress state. Update freely as work proceeds. - **Checkpoints landed:** - `163e68a` — plan doc - `2fc976b` — D15–D21 resolutions - - (next) — C-ABI demolition: tasks #3/#4/#5 done in one commit + - `ae4a903` — C-ABI demolition: tasks #3/#4/#5 done in one commit + - (next) — TDD characterization pass for demolition (Provider trait defaults, ProviderManager submenu/action dispatch, FromStr aliases, ItemSource simplification, CLI `-d` flag, plugin_list IPC rejection) - **Tasks done:** #1 inventory, #3 delete C-ABI, #4 delete Rune+Lua crates, #5 delete config_editor (scripts never lived in this repo) - **Tasks remaining (Phase 1):** #2 workspace collapse, #6 convert 8 plugins, #7 cargo features, #8 sys→power rename, #9 CLI subcommands, #10 auto-mode test, #11 final build+smoke - **Stray processes from inventory phase:** From 0a4a09037ece73ad8e100bd0d6f3ad0ee378eef7 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 02:05:26 +0200 Subject: [PATCH 05/23] refactor(v2): collapse owlry-core into owlry single crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace shrinks from 2 members to 1. The daemon, IPC layer, providers, config, frecency store, GTK4 UI, and CLI now live in a single `crates/owlry` crate exposing both a library (so integration tests can reach daemon types) and a binary. Structural changes: - crates/owlry-core/ deleted; all source moved into crates/owlry/src/ via git mv to preserve history - crates/owlry/src/lib.rs added with module declarations - crates/owlry/src/main.rs rewritten as thin entry that uses owlry::* - crates/owlry/src/providers/mod.rs absorbs owlry-core's providers/mod.rs and pulls dmenu into the same module tree - All owlry_core:: refs in src/ rewritten to crate:: - All owlry_core:: refs in tests/ rewritten to owlry:: - systemd/owlryd.service: ExecStart=/usr/bin/owlry -d (single binary) - justfile: drop owlry-core/owlry-lua/owlry-rune build steps; daemon runs via 'cargo run -p owlry -- -d' - owlry version: 1.0.10 -> 2.0.0-dev Tests: 178 still pass (156 lib + 14 ipc + 8 server). No test changes needed — moved files retained their inline test modules. Task #2 complete. --- Cargo.lock | 26 +- Cargo.toml | 1 - crates/owlry-core/Cargo.toml | 51 - crates/owlry-core/src/lib.rs | 8 - crates/owlry-core/src/main.rs | 33 - crates/owlry-core/src/providers/mod.rs | 1096 ----------------- crates/owlry/Cargo.toml | 50 +- crates/owlry/src/app.rs | 12 +- crates/owlry/src/backend.rs | 10 +- crates/owlry/src/cli.rs | 2 +- crates/owlry/src/client.rs | 6 +- .../{owlry-core => owlry}/src/config/mod.rs | 0 .../src/data/frecency.rs | 0 crates/{owlry-core => owlry}/src/data/mod.rs | 0 crates/{owlry-core => owlry}/src/filter.rs | 0 crates/{owlry-core => owlry}/src/ipc.rs | 0 crates/owlry/src/lib.rs | 20 + crates/owlry/src/main.rs | 34 +- crates/{owlry-core => owlry}/src/notify.rs | 0 crates/{owlry-core => owlry}/src/paths.rs | 0 .../src/providers/application.rs | 0 .../src/providers/calculator.rs | 0 .../src/providers/command.rs | 0 .../src/providers/converter/currency.rs | 0 .../src/providers/converter/mod.rs | 0 .../src/providers/converter/parser.rs | 0 .../src/providers/converter/units.rs | 0 crates/owlry/src/providers/dmenu.rs | 2 +- crates/owlry/src/providers/mod.rs | 1096 +++++++++++++++++ .../src/providers/system.rs | 0 crates/{owlry-core => owlry}/src/server.rs | 0 crates/owlry/src/theme.rs | 2 +- crates/owlry/src/ui/main_window.rs | 20 +- crates/owlry/src/ui/provider_meta.rs | 4 +- crates/owlry/src/ui/result_row.rs | 10 +- crates/owlry/src/ui/submenu.rs | 4 +- .../{owlry-core => owlry}/tests/ipc_test.rs | 2 +- .../tests/server_test.rs | 4 +- docs/RESTRUCTURE-V2.md | 3 +- justfile | 30 +- systemd/owlryd.service | 2 +- 41 files changed, 1223 insertions(+), 1305 deletions(-) delete mode 100644 crates/owlry-core/Cargo.toml delete mode 100644 crates/owlry-core/src/lib.rs delete mode 100644 crates/owlry-core/src/main.rs delete mode 100644 crates/owlry-core/src/providers/mod.rs rename crates/{owlry-core => owlry}/src/config/mod.rs (100%) rename crates/{owlry-core => owlry}/src/data/frecency.rs (100%) rename crates/{owlry-core => owlry}/src/data/mod.rs (100%) rename crates/{owlry-core => owlry}/src/filter.rs (100%) rename crates/{owlry-core => owlry}/src/ipc.rs (100%) create mode 100644 crates/owlry/src/lib.rs rename crates/{owlry-core => owlry}/src/notify.rs (100%) rename crates/{owlry-core => owlry}/src/paths.rs (100%) rename crates/{owlry-core => owlry}/src/providers/application.rs (100%) rename crates/{owlry-core => owlry}/src/providers/calculator.rs (100%) rename crates/{owlry-core => owlry}/src/providers/command.rs (100%) rename crates/{owlry-core => owlry}/src/providers/converter/currency.rs (100%) rename crates/{owlry-core => owlry}/src/providers/converter/mod.rs (100%) rename crates/{owlry-core => owlry}/src/providers/converter/parser.rs (100%) rename crates/{owlry-core => owlry}/src/providers/converter/units.rs (100%) rename crates/{owlry-core => owlry}/src/providers/system.rs (100%) rename crates/{owlry-core => owlry}/src/server.rs (100%) rename crates/{owlry-core => owlry}/tests/ipc_test.rs (98%) rename crates/{owlry-core => owlry}/tests/server_test.rs (98%) diff --git a/Cargo.lock b/Cargo.lock index 752b379..e975510 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1823,33 +1823,21 @@ dependencies = [ [[package]] name = "owlry" -version = "1.0.10" -dependencies = [ - "clap", - "env_logger", - "futures-channel", - "glib-build-tools", - "gtk4", - "gtk4-layer-shell", - "libc", - "log", - "owlry-core", - "serde", - "serde_json", - "toml 0.8.23", -] - -[[package]] -name = "owlry-core" -version = "1.3.6" +version = "2.0.0-dev" dependencies = [ "chrono", + "clap", "dirs", "env_logger", "expr-solver-lib", "freedesktop-desktop-entry", "fs2", + "futures-channel", "fuzzy-matcher", + "glib-build-tools", + "gtk4", + "gtk4-layer-shell", + "libc", "log", "notify-rust", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 8010327..49ea3b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,6 @@ resolver = "2" members = [ "crates/owlry", - "crates/owlry-core", ] # Shared workspace settings diff --git a/crates/owlry-core/Cargo.toml b/crates/owlry-core/Cargo.toml deleted file mode 100644 index 9a406f1..0000000 --- a/crates/owlry-core/Cargo.toml +++ /dev/null @@ -1,51 +0,0 @@ -[package] -name = "owlry-core" -version = "1.3.6" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "Core daemon for the Owlry application launcher" - -[lib] -name = "owlry_core" -path = "src/lib.rs" - -[[bin]] -name = "owlryd" -path = "src/main.rs" - -[dependencies] -# Provider system -fuzzy-matcher = "0.3" -freedesktop-desktop-entry = "0.8" - -# Data & config -serde = { version = "1", features = ["derive"] } -serde_json = "1" -toml = "0.8" -fs2 = "0.4" -chrono = { version = "0.4", features = ["serde"] } -dirs = "5" - -# Error handling -thiserror = "2" - -# Signal handling -signal-hook = "0.3" - -# Logging & notifications -log = "0.4" -env_logger = "0.11" -notify-rust = "4" - -# Built-in providers -expr-solver-lib = "1" -reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "blocking"] } - -[dev-dependencies] -tempfile = "3" - -[features] -default = [] -dev-logging = [] diff --git a/crates/owlry-core/src/lib.rs b/crates/owlry-core/src/lib.rs deleted file mode 100644 index 3ef0be7..0000000 --- a/crates/owlry-core/src/lib.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod config; -pub mod data; -pub mod filter; -pub mod ipc; -pub mod notify; -pub mod paths; -pub mod providers; -pub mod server; diff --git a/crates/owlry-core/src/main.rs b/crates/owlry-core/src/main.rs deleted file mode 100644 index 8931098..0000000 --- a/crates/owlry-core/src/main.rs +++ /dev/null @@ -1,33 +0,0 @@ -use log::info; - -use owlry_core::paths; -use owlry_core::server::Server; - -fn main() { - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); - - let sock = paths::socket_path(); - info!("Starting owlryd daemon..."); - - // Ensure the socket parent directory exists - if let Err(e) = paths::ensure_parent_dir(&sock) { - eprintln!("Failed to create socket directory: {e}"); - std::process::exit(1); - } - - let server = match Server::bind(&sock) { - Ok(s) => s, - Err(e) => { - eprintln!("Failed to start owlryd: {e}"); - std::process::exit(1); - } - }; - - // SIGTERM/SIGINT are handled inside Server::run() via signal-hook, - // which saves frecency before exiting. - - if let Err(e) = server.run() { - eprintln!("Server error: {e}"); - std::process::exit(1); - } -} diff --git a/crates/owlry-core/src/providers/mod.rs b/crates/owlry-core/src/providers/mod.rs deleted file mode 100644 index 7e6c75e..0000000 --- a/crates/owlry-core/src/providers/mod.rs +++ /dev/null @@ -1,1096 +0,0 @@ -// Core providers (compiled in) -mod application; -mod command; -pub(crate) mod calculator; -pub(crate) mod converter; -pub(crate) mod system; - -// Re-exports for core providers -pub use application::ApplicationProvider; -pub use command::CommandProvider; - -use chrono::Utc; -use fuzzy_matcher::FuzzyMatcher; -use fuzzy_matcher::skim::SkimMatcherV2; -use log::info; - -#[cfg(feature = "dev-logging")] -use log::debug; - -use std::sync::{Arc, RwLock}; - -use crate::config::Config; -use crate::data::FrecencyStore; - -/// Where a provider sits in the UI. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProviderPosition { - /// Normal results list. - Normal, - /// Widget — rendered at the top of the results, outside the normal flow. - Widget, -} - -impl ProviderPosition { - pub fn as_str(&self) -> &'static str { - match self { - ProviderPosition::Normal => "normal", - ProviderPosition::Widget => "widget", - } - } -} - -/// Metadata descriptor for an available provider (used by IPC/daemon API) -#[derive(Debug, Clone)] -pub struct ProviderDescriptor { - pub id: String, - pub name: String, - pub prefix: Option, - pub icon: String, - pub position: String, - pub tab_label: Option, - pub search_noun: Option, -} - -/// Trust level of a [`LaunchItem`]'s command, used to gate `sh -c` execution. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ItemSource { - /// Built-in provider compiled into the binary (trusted). - Core, - /// Script-defined provider (Lua, in Phase 3+) — user-installed, untrusted. - ScriptPlugin, -} - -impl ItemSource { - pub fn as_str(&self) -> &'static str { - match self { - ItemSource::Core => "core", - ItemSource::ScriptPlugin => "script_plugin", - } - } -} - -impl std::str::FromStr for ItemSource { - type Err = (); - fn from_str(s: &str) -> Result { - match s { - "script_plugin" => Ok(ItemSource::ScriptPlugin), - _ => Ok(ItemSource::Core), - } - } -} - -/// Represents a single searchable/launchable item -#[derive(Debug, Clone)] -pub struct LaunchItem { - #[allow(dead_code)] - pub id: String, - pub name: String, - pub description: Option, - pub icon: Option, - pub provider: ProviderType, - pub command: String, - pub terminal: bool, - /// Tags/categories for filtering (e.g., from .desktop Categories) - pub tags: Vec, - /// Trust level — gates `sh -c` execution for script plugin items. - pub source: ItemSource, -} - -/// Provider type identifier for filtering and badge display. -/// -/// - `Application`, `Command`, `Dmenu`: built-in core providers -/// - `Plugin(type_id)`: any other provider (compiled-in feature module or -/// future Lua-registered provider). The `type_id` is the provider's -/// stable identifier (e.g. `"bookmarks"`, `"uuctl"`, `"calc"`). -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum ProviderType { - Application, - Command, - Dmenu, - Plugin(String), -} - -impl std::str::FromStr for ProviderType { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "app" | "apps" | "application" | "applications" => Ok(ProviderType::Application), - "cmd" | "cmds" | "command" | "commands" => Ok(ProviderType::Command), - "dmenu" => Ok(ProviderType::Dmenu), - other => Ok(ProviderType::Plugin(other.to_string())), - } - } -} - -impl std::fmt::Display for ProviderType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ProviderType::Application => write!(f, "app"), - ProviderType::Command => write!(f, "cmd"), - ProviderType::Dmenu => write!(f, "dmenu"), - ProviderType::Plugin(type_id) => write!(f, "{}", type_id), - } - } -} - -/// Trait for all search providers. -/// -/// Static providers hold a refreshable item cache. Submenu and action support -/// are optional methods — implement them only if the provider produces -/// `SUBMENU:` items or handles plugin-defined action commands. -pub trait Provider: Send + Sync { - #[allow(dead_code)] - fn name(&self) -> &str; - fn provider_type(&self) -> ProviderType; - fn refresh(&mut self); - fn items(&self) -> &[LaunchItem]; - - /// Short label for UI tab button (e.g., "Shutdown"). None = use default. - fn tab_label(&self) -> Option<&str> { - None - } - /// Noun for search placeholder (e.g., "shutdown actions"). None = use default. - fn search_noun(&self) -> Option<&str> { - None - } - /// Optional search prefix (e.g. ":bm"). None = no prefix. - fn prefix(&self) -> Option<&str> { - None - } - /// Icon name (XDG icon theme). - fn icon(&self) -> &str { - "application-x-addon" - } - /// UI placement. - fn position(&self) -> ProviderPosition { - ProviderPosition::Normal - } - /// Priority hint for ordering. - fn priority(&self) -> u32 { - 0 - } - - /// Generate submenu actions for an item whose command starts with `SUBMENU:`. - /// `data` is everything after the type_id prefix. - /// Default: no submenu support. - fn submenu_actions(&self, _data: &str) -> Vec { - Vec::new() - } - - /// Handle a plugin-defined action command (e.g. `"POMODORO:start"`). - /// Returns true if the command was handled. - /// Default: no action support. - fn execute_action(&self, _command: &str) -> bool { - false - } -} - -/// Trait for built-in providers that produce results per-keystroke. -/// Unlike static `Provider`s which cache items via `refresh()`/`items()`, -/// dynamic providers generate results on every query. -pub trait DynamicProvider: Send + Sync { - #[allow(dead_code)] - fn name(&self) -> &str; - fn provider_type(&self) -> ProviderType; - fn query(&self, query: &str) -> Vec; - 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. -pub struct ProviderManager { - /// Static providers (apps, commands, systemd, etc.). - providers: Vec>, - /// Dynamic providers (calculator, converter, websearch, filesearch). - /// Queried per-keystroke, not cached. - builtin_dynamic: Vec>, - /// Fuzzy matcher for search. - matcher: SkimMatcherV2, -} - -impl ProviderManager { - /// Create a new ProviderManager from a pre-built list of providers. - /// - /// Used by tests and by the dmenu-mode client. The daemon uses - /// [`Self::new_with_config`] which builds the provider set from config. - pub fn new( - core_providers: Vec>, - dynamic: Vec>, - ) -> Self { - let mut manager = Self { - providers: core_providers, - builtin_dynamic: dynamic, - matcher: SkimMatcherV2::default(), - }; - manager.refresh_all(); - manager - } - - /// Build a ProviderManager for the daemon, sourcing enabled providers from config. - /// - /// Only built-in / compiled-in providers are registered here. Future Lua-defined - /// providers (Phase 3+) are added via [`Self::add_provider`] after construction. - pub fn new_with_config(config: Arc>) -> Self { - let (calc_enabled, conv_enabled, sys_enabled) = match config.read() { - Ok(cfg) => ( - cfg.providers.calculator, - cfg.providers.converter, - cfg.providers.system, - ), - Err(_) => { - log::warn!("Config lock poisoned during provider init; using defaults"); - (true, true, true) - } - }; - - let mut core_providers: Vec> = vec![ - Box::new(ApplicationProvider::new()), - Box::new(CommandProvider::new()), - ]; - - if sys_enabled { - core_providers.push(Box::new(system::SystemProvider::new())); - info!("Registered built-in system provider"); - } - - let mut builtin_dynamic: Vec> = Vec::new(); - if calc_enabled { - builtin_dynamic.push(Box::new(calculator::CalculatorProvider)); - info!("Registered built-in calculator provider"); - } - if conv_enabled { - builtin_dynamic.push(Box::new(converter::ConverterProvider::new())); - info!("Registered built-in converter provider"); - } - - Self::new(core_providers, builtin_dynamic) - } - - #[allow(dead_code)] - pub fn is_dmenu_mode(&self) -> bool { - self.providers - .iter() - .any(|p| p.provider_type() == ProviderType::Dmenu) - } - - pub fn refresh_all(&mut self) { - for provider in &mut self.providers { - provider.refresh(); - info!( - "Provider '{}' loaded {} items", - provider.name(), - provider.items().len() - ); - } - // Dynamic providers don't need refresh (they query on demand). - } - - /// Register an additional provider at runtime. - /// - /// Used by Phase 3+ Lua config to add user-defined providers after the - /// daemon has booted. The provider's `refresh()` is called immediately. - #[allow(dead_code)] - pub fn add_provider(&mut self, mut provider: Box) { - provider.refresh(); - info!("Registered provider: {}", provider.name()); - self.providers.push(provider); - } - - /// Execute a plugin-defined action command. - /// - /// Command format: `PLUGIN_ID:action_data` (e.g. `"POMODORO:start"`). - /// Returns true if a provider handled the command. - pub fn execute_plugin_action(&self, command: &str) -> bool { - for provider in &self.providers { - if provider.execute_action(command) { - return true; - } - } - for provider in &self.builtin_dynamic { - if provider.execute_action(command) { - return true; - } - } - false - } - - #[allow(dead_code)] - pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> { - if query.is_empty() { - return self - .providers - .iter() - .flat_map(|p| p.items().iter().cloned()) - .take(max_results) - .map(|item| (item, 0)) - .collect(); - } - - let mut results: Vec<(LaunchItem, i64)> = self - .providers - .iter() - .flat_map(|p| p.items().iter()) - .filter_map(|item| { - let name_score = self.matcher.fuzzy_match(&item.name, query); - let desc_score = item - .description - .as_ref() - .and_then(|d| self.matcher.fuzzy_match(d, query)); - let score = match (name_score, desc_score) { - (Some(n), Some(d)) => Some(n.max(d)), - (Some(n), None) => Some(n), - (None, Some(d)) => Some(d / 2), - (None, None) => None, - }; - score.map(|s| (item.clone(), s)) - }) - .collect(); - - results.sort_by(|a, b| b.1.cmp(&a.1)); - results.truncate(max_results); - results - } - - /// Search with provider filtering. - #[allow(dead_code)] - pub fn search_filtered( - &self, - query: &str, - max_results: usize, - filter: &crate::filter::ProviderFilter, - tag_filter: Option<&str>, - ) -> Vec<(LaunchItem, i64)> { - let all_items = self - .providers - .iter() - .filter(|p| filter.is_active(p.provider_type())) - .flat_map(|p| p.items().iter().cloned()) - .filter(|item| tag_filter.is_none_or(|t| item.tags.iter().any(|it| it == t))); - - if query.is_empty() { - return all_items.take(max_results).map(|item| (item, 0)).collect(); - } - - let mut results: Vec<(LaunchItem, i64)> = all_items - .filter_map(|item| { - let name_score = self.matcher.fuzzy_match(&item.name, query); - let desc_score = item - .description - .as_ref() - .and_then(|d| self.matcher.fuzzy_match(d, query)); - let score = match (name_score, desc_score) { - (Some(n), Some(d)) => Some(n.max(d)), - (Some(n), None) => Some(n), - (None, Some(d)) => Some(d / 2), - (None, None) => None, - }; - score.map(|s| (item, s)) - }) - .collect(); - - results.sort_by(|a, b| b.1.cmp(&a.1)); - results.truncate(max_results); - results - } - - /// Search with frecency boosting, dynamic providers, and tag filtering. - pub fn search_with_frecency( - &self, - query: &str, - max_results: usize, - filter: &crate::filter::ProviderFilter, - frecency: &FrecencyStore, - frecency_weight: f64, - tag_filter: Option<&str>, - ) -> Vec<(LaunchItem, i64)> { - #[cfg(feature = "dev-logging")] - debug!( - "[Search] query={:?}, max={}, frecency_weight={}", - query, max_results, frecency_weight - ); - - let now = Utc::now(); - let mut results: Vec<(LaunchItem, i64)> = Vec::new(); - - // Widget providers contribute on empty query (no prefix active). - if filter.active_prefix().is_none() && query.is_empty() { - for provider in &self.providers { - if provider.position() != ProviderPosition::Widget { - continue; - } - let base_score = provider.priority() as i64; - for (idx, item) in provider.items().iter().enumerate() { - results.push((item.clone(), base_score - idx as i64)); - } - } - } - - // Dynamic providers (calculator, converter, etc.) — only when there's a query. - if !query.is_empty() { - for provider in &self.builtin_dynamic { - if !filter.is_active(provider.provider_type()) { - continue; - } - let dynamic_results = provider.query(query); - let base_score = provider.priority() as i64; - let grouping_bonus: i64 = match provider.provider_type() { - ProviderType::Plugin(ref id) if matches!(id.as_str(), "calc" | "conv") => { - 10_000 - } - _ => 0, - }; - for (idx, item) in dynamic_results.into_iter().enumerate() { - results.push((item, base_score + grouping_bonus - idx as i64)); - } - } - } - - // Empty query (after widgets) — frecency-sorted items. - if query.is_empty() { - let mut scored_refs: Vec<(&LaunchItem, i64)> = self - .providers - .iter() - .filter(|p| p.position() != ProviderPosition::Widget) - .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; - } - - // Regular search with frecency boost and tag matching. - let score_item = |item: &LaunchItem| -> Option { - if let Some(tag) = tag_filter - && !item.tags.iter().any(|t| t.to_lowercase().contains(tag)) - { - return None; - } - let name_score = self.matcher.fuzzy_match(&item.name, query); - let desc_score = item - .description - .as_ref() - .and_then(|d| self.matcher.fuzzy_match(d, query)); - let tag_score = item - .tags - .iter() - .filter_map(|t| self.matcher.fuzzy_match(t, query)) - .max() - .map(|s| s / 3); - - let base_score = match (name_score, desc_score, tag_score) { - (Some(n), Some(d), Some(t)) => Some(n.max(d).max(t)), - (Some(n), Some(d), None) => Some(n.max(d)), - (Some(n), None, Some(t)) => Some(n.max(t)), - (Some(n), None, None) => Some(n), - (None, Some(d), Some(t)) => Some((d / 2).max(t)), - (None, Some(d), None) => Some(d / 2), - (None, None, Some(t)) => Some(t), - (None, None, None) => None, - }; - - 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 - }) - }; - - let mut scored_refs: Vec<(&LaunchItem, i64)> = Vec::new(); - for provider in &self.providers { - if provider.position() == ProviderPosition::Widget { - continue; - } - 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)); - } - } - } - - 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); - - #[cfg(feature = "dev-logging")] - { - debug!("[Search] Returning {} results", results.len()); - for (i, (item, score)) in results.iter().take(5).enumerate() { - debug!( - "[Search] #{}: {} (score={}, provider={:?})", - i + 1, - item.name, - score, - item.provider - ); - } - if results.len() > 5 { - debug!("[Search] ... and {} more", results.len() - 5); - } - } - - results - } - - /// Get all available provider types (for UI tabs). - #[allow(dead_code)] - pub fn available_provider_types(&self) -> Vec { - self.providers.iter().map(|p| p.provider_type()).collect() - } - - /// Get descriptors for all registered providers. - /// - /// Used by the IPC server to report what providers are available to clients. - pub fn available_providers(&self) -> Vec { - let mut descs = Vec::new(); - - for provider in &self.providers { - let (id, default_prefix, default_icon) = match provider.provider_type() { - ProviderType::Application => ( - "app".to_string(), - Some(":app".to_string()), - "application-x-executable", - ), - ProviderType::Command => ( - "cmd".to_string(), - Some(":cmd".to_string()), - "utilities-terminal", - ), - ProviderType::Dmenu => ("dmenu".to_string(), None, "view-list-symbolic"), - ProviderType::Plugin(type_id) => (type_id, None, "application-x-addon"), - }; - descs.push(ProviderDescriptor { - id, - name: provider.name().to_string(), - prefix: provider.prefix().map(String::from).or(default_prefix), - icon: { - let trait_icon = provider.icon(); - if trait_icon == "application-x-addon" { - default_icon.to_string() - } else { - trait_icon.to_string() - } - }, - position: provider.position().as_str().to_string(), - tab_label: provider.tab_label().map(String::from), - search_noun: provider.search_noun().map(String::from), - }); - } - - descs - } - - /// Refresh a specific provider by its type_id. - pub fn refresh_provider(&mut self, provider_id: &str) { - for provider in &mut self.providers { - let matches = match provider.provider_type() { - ProviderType::Application => provider_id == "app", - ProviderType::Command => provider_id == "cmd", - ProviderType::Dmenu => provider_id == "dmenu", - ProviderType::Plugin(ref id) => provider_id == id, - }; - if matches { - provider.refresh(); - info!("Refreshed provider '{}'", provider.name()); - return; - } - } - info!("Provider '{}' not found for refresh", provider_id); - } - - /// Query a provider for submenu actions. - /// - /// Called when a user selects a `SUBMENU:plugin_id:data` item. The provider's - /// `submenu_actions(data)` is invoked to produce the action list. - pub fn query_submenu_actions( - &self, - plugin_id: &str, - data: &str, - display_name: &str, - ) -> Option<(String, Vec)> { - #[cfg(feature = "dev-logging")] - debug!("[Submenu] Querying provider '{}' with data: {}", plugin_id, data); - - for provider in &self.providers { - let matches = match provider.provider_type() { - ProviderType::Plugin(ref id) => id == plugin_id, - _ => false, - }; - if matches { - let actions = provider.submenu_actions(data); - if !actions.is_empty() { - return Some((display_name.to_string(), actions)); - } - } - } - - #[cfg(feature = "dev-logging")] - debug!("[Submenu] No submenu actions for provider '{}'", plugin_id); - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Minimal mock provider for testing ProviderManager - struct MockProvider { - name: String, - provider_type: ProviderType, - items: Vec, - refresh_count: usize, - } - - impl MockProvider { - fn new(name: &str, provider_type: ProviderType) -> Self { - Self { - name: name.to_string(), - provider_type, - items: Vec::new(), - refresh_count: 0, - } - } - - fn with_items(mut self, items: Vec) -> Self { - self.items = items; - self - } - } - - impl Provider for MockProvider { - fn name(&self) -> &str { - &self.name - } - - fn provider_type(&self) -> ProviderType { - self.provider_type.clone() - } - - fn refresh(&mut self) { - self.refresh_count += 1; - } - - fn items(&self) -> &[LaunchItem] { - &self.items - } - } - - fn make_item(id: &str, name: &str, provider: ProviderType) -> LaunchItem { - LaunchItem { - id: id.to_string(), - name: name.to_string(), - description: None, - icon: None, - provider, - command: format!("run-{}", id), - terminal: false, - tags: Vec::new(), - source: ItemSource::Core, - } - } - - #[test] - fn test_available_providers_core_only() { - let providers: Vec> = vec![ - Box::new(MockProvider::new("Applications", ProviderType::Application)), - Box::new(MockProvider::new("Commands", ProviderType::Command)), - ]; - let pm = ProviderManager::new(providers, Vec::new()); - let descs = pm.available_providers(); - assert_eq!(descs.len(), 2); - assert_eq!(descs[0].id, "app"); - assert_eq!(descs[0].name, "Applications"); - assert_eq!(descs[0].prefix, Some(":app".to_string())); - assert_eq!(descs[0].icon, "application-x-executable"); - assert_eq!(descs[0].position, "normal"); - assert_eq!(descs[1].id, "cmd"); - assert_eq!(descs[1].name, "Commands"); - } - - #[test] - fn test_available_providers_dmenu() { - let providers: Vec> = - vec![Box::new(MockProvider::new("dmenu", ProviderType::Dmenu))]; - let pm = ProviderManager::new(providers, Vec::new()); - let descs = pm.available_providers(); - assert_eq!(descs.len(), 1); - assert_eq!(descs[0].id, "dmenu"); - assert!(descs[0].prefix.is_none()); - } - - #[test] - fn test_available_provider_types() { - let providers: Vec> = vec![ - Box::new(MockProvider::new("Applications", ProviderType::Application)), - Box::new(MockProvider::new("Commands", ProviderType::Command)), - ]; - let pm = ProviderManager::new(providers, Vec::new()); - let types = pm.available_provider_types(); - assert_eq!(types.len(), 2); - assert!(types.contains(&ProviderType::Application)); - assert!(types.contains(&ProviderType::Command)); - } - - #[test] - fn test_refresh_provider_core() { - let app = MockProvider::new("Applications", ProviderType::Application); - let cmd = MockProvider::new("Commands", ProviderType::Command); - let providers: Vec> = vec![Box::new(app), Box::new(cmd)]; - let mut pm = ProviderManager::new(providers, Vec::new()); - - pm.refresh_provider("app"); - pm.refresh_provider("cmd"); - // Just verifying it doesn't panic. - } - - #[test] - fn test_refresh_provider_unknown_does_not_panic() { - let providers: Vec> = vec![Box::new(MockProvider::new( - "Applications", - ProviderType::Application, - ))]; - let mut pm = ProviderManager::new(providers, Vec::new()); - pm.refresh_provider("nonexistent"); - } - - #[test] - fn test_search_with_core_providers() { - let items = vec![ - make_item("firefox", "Firefox", ProviderType::Application), - make_item("vim", "Vim", ProviderType::Application), - ]; - let provider = - MockProvider::new("Applications", ProviderType::Application).with_items(items); - let providers: Vec> = vec![Box::new(provider)]; - let pm = ProviderManager::new(providers, Vec::new()); - - let results = pm.search("fire", 10); - assert_eq!(results.len(), 1); - assert_eq!(results[0].0.name, "Firefox"); - } - - // ========================================================================= - // Tests for behavior introduced in the v2 C-ABI demolition (commit ae4a903) - // ========================================================================= - - /// Provider impl that overrides every trait method to verify customization - /// flows through `ProviderManager::available_providers()` and submenu/action - /// dispatch. - struct RichMockProvider { - type_id: String, - items: Vec, - submenu: Vec, - action_handled_for: Option, - } - - impl Provider for RichMockProvider { - fn name(&self) -> &str { - "Rich" - } - fn provider_type(&self) -> ProviderType { - ProviderType::Plugin(self.type_id.clone()) - } - fn refresh(&mut self) {} - fn items(&self) -> &[LaunchItem] { - &self.items - } - fn prefix(&self) -> Option<&str> { - Some(":rich") - } - fn icon(&self) -> &str { - "rich-icon" - } - fn position(&self) -> ProviderPosition { - ProviderPosition::Widget - } - fn priority(&self) -> u32 { - 42 - } - fn tab_label(&self) -> Option<&str> { - Some("Rich") - } - fn search_noun(&self) -> Option<&str> { - Some("rich things") - } - fn submenu_actions(&self, _data: &str) -> Vec { - self.submenu.clone() - } - fn execute_action(&self, command: &str) -> bool { - self.action_handled_for - .as_ref() - .map(|prefix| command.starts_with(prefix.as_str())) - .unwrap_or(false) - } - } - - #[test] - fn provider_trait_default_methods_return_documented_values() { - // MockProvider does not override any optional method; defaults must hold. - let p = MockProvider::new("M", ProviderType::Application); - assert_eq!(p.prefix(), None); - assert_eq!(p.icon(), "application-x-addon"); - assert_eq!(p.position(), ProviderPosition::Normal); - assert_eq!(p.priority(), 0); - assert_eq!(p.tab_label(), None); - assert_eq!(p.search_noun(), None); - assert!(p.submenu_actions("anything").is_empty()); - assert!(!p.execute_action("MOCK:anything")); - } - - #[test] - fn provider_position_as_str_matches_ipc_strings() { - assert_eq!(ProviderPosition::Normal.as_str(), "normal"); - assert_eq!(ProviderPosition::Widget.as_str(), "widget"); - } - - #[test] - fn provider_type_from_str_accepts_plural_aliases() { - // After the demolition, FromStr accepts both singular and plural aliases. - use std::str::FromStr; - assert_eq!(ProviderType::from_str("app").unwrap(), ProviderType::Application); - assert_eq!(ProviderType::from_str("apps").unwrap(), ProviderType::Application); - assert_eq!( - ProviderType::from_str("application").unwrap(), - ProviderType::Application - ); - assert_eq!( - ProviderType::from_str("applications").unwrap(), - ProviderType::Application - ); - assert_eq!(ProviderType::from_str("cmd").unwrap(), ProviderType::Command); - assert_eq!(ProviderType::from_str("cmds").unwrap(), ProviderType::Command); - assert_eq!( - ProviderType::from_str("command").unwrap(), - ProviderType::Command - ); - assert_eq!( - ProviderType::from_str("commands").unwrap(), - ProviderType::Command - ); - assert_eq!(ProviderType::from_str("dmenu").unwrap(), ProviderType::Dmenu); - // Anything unknown becomes Plugin(s) — preserves user-defined provider IDs. - assert_eq!( - ProviderType::from_str("bookmarks").unwrap(), - ProviderType::Plugin("bookmarks".into()) - ); - assert_eq!( - ProviderType::from_str("uuctl").unwrap(), - ProviderType::Plugin("uuctl".into()) - ); - } - - #[test] - fn item_source_from_str_maps_unknown_to_core() { - // NativePlugin variant is gone; unknown strings (including the old - // "native_plugin" tag from pre-2.0 daemons) decode to Core. - use std::str::FromStr; - assert_eq!(ItemSource::from_str("core"), Ok(ItemSource::Core)); - assert_eq!( - ItemSource::from_str("script_plugin"), - Ok(ItemSource::ScriptPlugin) - ); - assert_eq!(ItemSource::from_str("native_plugin"), Ok(ItemSource::Core)); - assert_eq!(ItemSource::from_str(""), Ok(ItemSource::Core)); - assert_eq!(ItemSource::from_str("anything-else"), Ok(ItemSource::Core)); - } - - #[test] - fn item_source_as_str_only_emits_supported_variants() { - assert_eq!(ItemSource::Core.as_str(), "core"); - assert_eq!(ItemSource::ScriptPlugin.as_str(), "script_plugin"); - } - - #[test] - fn add_provider_refreshes_and_appends() { - let mut pm = ProviderManager::new(Vec::new(), Vec::new()); - assert_eq!(pm.available_provider_types().len(), 0); - - let prov = MockProvider::new("Late", ProviderType::Plugin("late".into())); - pm.add_provider(Box::new(prov)); - - let types = pm.available_provider_types(); - assert_eq!(types.len(), 1); - assert_eq!(types[0], ProviderType::Plugin("late".into())); - // add_provider must call refresh() — checked indirectly by the impl - // contract; refresh_count on MockProvider was bumped, but we can't - // peek through the Box. The public observable is that items() - // is callable without panic, which we exercise here. - assert!(pm.available_providers()[0].id == "late"); - } - - #[test] - fn available_providers_uses_trait_overrides_for_plugin_type() { - let prov = RichMockProvider { - type_id: "rich".into(), - items: Vec::new(), - submenu: Vec::new(), - action_handled_for: None, - }; - let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); - let descs = pm.available_providers(); - assert_eq!(descs.len(), 1); - let d = &descs[0]; - assert_eq!(d.id, "rich"); - assert_eq!(d.prefix.as_deref(), Some(":rich")); - assert_eq!(d.icon, "rich-icon"); - assert_eq!(d.position, "widget"); - assert_eq!(d.tab_label.as_deref(), Some("Rich")); - assert_eq!(d.search_noun.as_deref(), Some("rich things")); - } - - #[test] - fn query_submenu_actions_returns_some_when_provider_matches_and_has_actions() { - let prov = RichMockProvider { - type_id: "uuctl".into(), - items: Vec::new(), - submenu: vec![make_item( - "start", - "Start service", - ProviderType::Plugin("uuctl".into()), - )], - action_handled_for: None, - }; - let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); - - let result = pm.query_submenu_actions("uuctl", "foo.service:true", "foo"); - assert!(result.is_some(), "expected Some when provider matches and returns actions"); - let (display, actions) = result.unwrap(); - assert_eq!(display, "foo"); - assert_eq!(actions.len(), 1); - assert_eq!(actions[0].name, "Start service"); - } - - #[test] - fn query_submenu_actions_returns_none_when_no_provider_matches() { - let prov = RichMockProvider { - type_id: "uuctl".into(), - items: Vec::new(), - submenu: vec![make_item("x", "x", ProviderType::Plugin("uuctl".into()))], - action_handled_for: None, - }; - let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); - assert!(pm.query_submenu_actions("does-not-exist", "data", "name").is_none()); - } - - #[test] - fn query_submenu_actions_returns_none_when_provider_returns_empty_actions() { - let prov = RichMockProvider { - type_id: "uuctl".into(), - items: Vec::new(), - submenu: Vec::new(), // matching provider but no actions - action_handled_for: None, - }; - let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); - assert!(pm.query_submenu_actions("uuctl", "data", "name").is_none()); - } - - #[test] - fn execute_plugin_action_returns_true_when_static_provider_handles() { - let prov = RichMockProvider { - type_id: "pomodoro".into(), - items: Vec::new(), - submenu: Vec::new(), - action_handled_for: Some("POMODORO:".into()), - }; - let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); - assert!(pm.execute_plugin_action("POMODORO:start")); - } - - #[test] - fn execute_plugin_action_returns_true_when_dynamic_provider_handles() { - struct DynStub { - handles: String, - } - impl DynamicProvider for DynStub { - fn name(&self) -> &str { - "dyn" - } - fn provider_type(&self) -> ProviderType { - ProviderType::Plugin("dyn".into()) - } - fn query(&self, _q: &str) -> Vec { - Vec::new() - } - fn priority(&self) -> u32 { - 0 - } - fn execute_action(&self, command: &str) -> bool { - command.starts_with(&self.handles) - } - } - - let pm = ProviderManager::new( - Vec::new(), - vec![Box::new(DynStub { - handles: "DYN:".into(), - })], - ); - assert!(pm.execute_plugin_action("DYN:thing")); - } - - #[test] - fn execute_plugin_action_returns_false_when_nothing_handles() { - let prov = RichMockProvider { - type_id: "x".into(), - items: Vec::new(), - submenu: Vec::new(), - action_handled_for: Some("X:".into()), - }; - let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); - assert!(!pm.execute_plugin_action("UNRELATED:command")); - } - - #[test] - fn provider_manager_new_with_no_providers_does_not_panic() { - // Regression guard: the daemon may be configured to disable every - // provider; construction must still succeed. - let pm = ProviderManager::new(Vec::new(), Vec::new()); - assert_eq!(pm.available_providers().len(), 0); - assert!(!pm.execute_plugin_action("ANYTHING:foo")); - assert!(pm.query_submenu_actions("anything", "data", "n").is_none()); - } -} diff --git a/crates/owlry/Cargo.toml b/crates/owlry/Cargo.toml index 506acad..da5350a 100644 --- a/crates/owlry/Cargo.toml +++ b/crates/owlry/Cargo.toml @@ -1,19 +1,24 @@ [package] name = "owlry" -version = "1.0.10" +version = "2.0.0-dev" edition = "2024" rust-version = "1.90" description = "A lightweight, owl-themed application launcher for Wayland" -authors = ["Your Name "] +authors = ["Owlibou"] license = "GPL-3.0-or-later" repository = "https://somegit.dev/Owlibou/owlry" keywords = ["launcher", "wayland", "gtk4", "linux"] categories = ["gui"] -[dependencies] -# Core backend library -owlry-core = { path = "../owlry-core" } +[lib] +name = "owlry" +path = "src/lib.rs" +[[bin]] +name = "owlry" +path = "src/main.rs" + +[dependencies] # GTK4 for the UI gtk4 = { version = "0.10", features = ["v4_12"] } @@ -23,19 +28,37 @@ gtk4-layer-shell = "0.7" # Low-level syscalls for stdin detection (dmenu mode) libc = "0.2" -# Logging +# Logging & notifications log = "0.4" env_logger = "0.11" - -# Configuration (needed for config types used in app.rs/theme.rs) -serde = { version = "1", features = ["derive"] } -toml = "0.8" +notify-rust = "4" # CLI argument parsing clap = { version = "4", features = ["derive"] } -# IPC (Request/Response serialization) +# Configuration & serialization +serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = "0.8" + +# Provider system & search +fuzzy-matcher = "0.3" +freedesktop-desktop-entry = "0.8" + +# Data & filesystem +fs2 = "0.4" +chrono = { version = "0.4", features = ["serde"] } +dirs = "5" + +# Error handling +thiserror = "2" + +# Signal handling (daemon) +signal-hook = "0.3" + +# Built-in providers +expr-solver-lib = "1" +reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "blocking"] } # Async oneshot channel (background thread -> main loop) futures-channel = "0.3" @@ -44,7 +67,10 @@ futures-channel = "0.3" # GResource compilation for bundled icons glib-build-tools = "0.20" +[dev-dependencies] +tempfile = "3" + [features] default = [] # Enable verbose debug logging (for development/testing builds) -dev-logging = ["owlry-core/dev-logging"] +dev-logging = [] diff --git a/crates/owlry/src/app.rs b/crates/owlry/src/app.rs index 77fb6e3..75148a4 100644 --- a/crates/owlry/src/app.rs +++ b/crates/owlry/src/app.rs @@ -8,11 +8,11 @@ use gtk4::prelude::*; use gtk4::{Application, CssProvider, gio}; use gtk4_layer_shell::{Edge, Layer, LayerShell}; use log::{debug, info, warn}; -use owlry_core::config::Config; -use owlry_core::data::FrecencyStore; -use owlry_core::filter::ProviderFilter; -use owlry_core::paths; -use owlry_core::providers::{Provider, ProviderManager, ProviderType}; +use crate::config::Config; +use crate::data::FrecencyStore; +use crate::filter::ProviderFilter; +use crate::paths; +use crate::providers::{Provider, ProviderManager, ProviderType}; use std::cell::RefCell; use std::rc::Rc; @@ -149,7 +149,7 @@ impl OwlryApp { /// All other providers belong to the daemon (Phase 1 keeps the daemon as the /// primary path). fn create_local_backend(_config: &Config) -> SearchBackend { - use owlry_core::providers::{ApplicationProvider, CommandProvider}; + use crate::providers::{ApplicationProvider, CommandProvider}; let core_providers: Vec> = vec![ Box::new(ApplicationProvider::new()), diff --git a/crates/owlry/src/backend.rs b/crates/owlry/src/backend.rs index aa36184..5c2f9fa 100644 --- a/crates/owlry/src/backend.rs +++ b/crates/owlry/src/backend.rs @@ -5,11 +5,11 @@ use crate::client::CoreClient; use log::warn; -use owlry_core::config::Config; -use owlry_core::data::FrecencyStore; -use owlry_core::filter::ProviderFilter; -use owlry_core::ipc::{ProviderDesc, ResultItem}; -use owlry_core::providers::{ItemSource, LaunchItem, ProviderManager, ProviderType}; +use crate::config::Config; +use crate::data::FrecencyStore; +use crate::filter::ProviderFilter; +use crate::ipc::{ProviderDesc, ResultItem}; +use crate::providers::{ItemSource, LaunchItem, ProviderManager, ProviderType}; use std::sync::{Arc, Mutex}; /// Parameters needed to run a search query on a background thread. diff --git a/crates/owlry/src/cli.rs b/crates/owlry/src/cli.rs index 1fa4640..8757d77 100644 --- a/crates/owlry/src/cli.rs +++ b/crates/owlry/src/cli.rs @@ -2,7 +2,7 @@ use clap::Parser; -use owlry_core::providers::ProviderType; +use crate::providers::ProviderType; #[derive(Parser, Debug, Clone)] #[command( diff --git a/crates/owlry/src/client.rs b/crates/owlry/src/client.rs index 898ba6f..de3ebb1 100644 --- a/crates/owlry/src/client.rs +++ b/crates/owlry/src/client.rs @@ -3,7 +3,7 @@ use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::time::Duration; -use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem}; +use crate::ipc::{ProviderDesc, Request, Response, ResultItem}; /// Maximum allowed size for a single IPC response line (4 MiB). /// Larger than the request limit because responses carry result sets. @@ -111,10 +111,10 @@ impl CoreClient { /// Default socket path: `$XDG_RUNTIME_DIR/owlry/owlry.sock`. /// - /// Delegates to `owlry_core::paths::socket_path()` to keep a single + /// Delegates to `crate::paths::socket_path()` to keep a single /// source of truth. pub fn socket_path() -> PathBuf { - owlry_core::paths::socket_path() + crate::paths::socket_path() } /// Send a search query and return matching results. diff --git a/crates/owlry-core/src/config/mod.rs b/crates/owlry/src/config/mod.rs similarity index 100% rename from crates/owlry-core/src/config/mod.rs rename to crates/owlry/src/config/mod.rs diff --git a/crates/owlry-core/src/data/frecency.rs b/crates/owlry/src/data/frecency.rs similarity index 100% rename from crates/owlry-core/src/data/frecency.rs rename to crates/owlry/src/data/frecency.rs diff --git a/crates/owlry-core/src/data/mod.rs b/crates/owlry/src/data/mod.rs similarity index 100% rename from crates/owlry-core/src/data/mod.rs rename to crates/owlry/src/data/mod.rs diff --git a/crates/owlry-core/src/filter.rs b/crates/owlry/src/filter.rs similarity index 100% rename from crates/owlry-core/src/filter.rs rename to crates/owlry/src/filter.rs diff --git a/crates/owlry-core/src/ipc.rs b/crates/owlry/src/ipc.rs similarity index 100% rename from crates/owlry-core/src/ipc.rs rename to crates/owlry/src/ipc.rs diff --git a/crates/owlry/src/lib.rs b/crates/owlry/src/lib.rs new file mode 100644 index 0000000..7b26b4c --- /dev/null +++ b/crates/owlry/src/lib.rs @@ -0,0 +1,20 @@ +//! Owlry — Wayland application launcher. +//! +//! Single-crate layout (v2): the daemon, IPC layer, providers, and GTK4 UI +//! all live here. The binary entry point in `main.rs` selects between UI +//! launch (default) and daemon mode (`-d` / `--daemon`). + +pub mod app; +pub mod backend; +pub mod cli; +pub mod client; +pub mod config; +pub mod data; +pub mod filter; +pub mod ipc; +pub mod notify; +pub mod paths; +pub mod providers; +pub mod server; +pub mod theme; +pub mod ui; diff --git a/crates/owlry/src/main.rs b/crates/owlry/src/main.rs index d3e501f..96c1c11 100644 --- a/crates/owlry/src/main.rs +++ b/crates/owlry/src/main.rs @@ -1,16 +1,10 @@ -mod app; -mod backend; -mod cli; -pub mod client; -mod providers; -mod theme; -mod ui; - -use app::OwlryApp; -use cli::CliArgs; use log::{info, warn}; use std::os::unix::io::AsRawFd; +use owlry::app::OwlryApp; +use owlry::cli::CliArgs; +use owlry::{client, paths, server}; + #[cfg(feature = "dev-logging")] use log::debug; @@ -22,12 +16,11 @@ use log::debug; fn try_acquire_lock() -> Option { use std::os::unix::fs::OpenOptionsExt; - let lock_path = owlry_core::paths::socket_path() + let lock_path = paths::socket_path() .parent() .unwrap() .join("owlry-ui.lock"); - // Ensure the parent directory exists if let Some(parent) = lock_path.parent() { let _ = std::fs::create_dir_all(parent); } @@ -51,17 +44,21 @@ fn main() { // -d / --daemon: run the daemon in-process and exit when it stops. if args.daemon { - let default_level = if cfg!(feature = "dev-logging") { "debug" } else { "info" }; + let default_level = if cfg!(feature = "dev-logging") { + "debug" + } else { + "info" + }; env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_level)) .format_timestamp_millis() .init(); - let sock = owlry_core::paths::socket_path(); - if let Err(e) = owlry_core::paths::ensure_parent_dir(&sock) { + let sock = paths::socket_path(); + if let Err(e) = paths::ensure_parent_dir(&sock) { eprintln!("Failed to create socket directory: {e}"); std::process::exit(1); } - match owlry_core::server::Server::bind(&sock) { + match server::Server::bind(&sock) { Ok(server) => { if let Err(e) = server.run() { eprintln!("Server error: {e}"); @@ -76,7 +73,7 @@ fn main() { } } - // No subcommand - launch the app + // Default: launch the UI. let default_level = if cfg!(feature = "dev-logging") { "debug" } else { @@ -100,7 +97,6 @@ fn main() { let _lock_guard = match try_acquire_lock() { Some(file) => file, None => { - // Another instance holds the lock — send toggle to daemon and exit info!("Another owlry instance detected, sending toggle"); let socket_path = client::CoreClient::socket_path(); if let Ok(mut client) = client::CoreClient::connect(&socket_path) { @@ -118,7 +114,7 @@ fn main() { info!("Starting Owlry launcher"); - // Diagnostic: log critical environment variables + // Diagnostic: log critical environment variables. let home = std::env::var("HOME").unwrap_or_else(|_| "".to_string()); let path = std::env::var("PATH").unwrap_or_else(|_| "".to_string()); let xdg_data = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| "".to_string()); diff --git a/crates/owlry-core/src/notify.rs b/crates/owlry/src/notify.rs similarity index 100% rename from crates/owlry-core/src/notify.rs rename to crates/owlry/src/notify.rs diff --git a/crates/owlry-core/src/paths.rs b/crates/owlry/src/paths.rs similarity index 100% rename from crates/owlry-core/src/paths.rs rename to crates/owlry/src/paths.rs diff --git a/crates/owlry-core/src/providers/application.rs b/crates/owlry/src/providers/application.rs similarity index 100% rename from crates/owlry-core/src/providers/application.rs rename to crates/owlry/src/providers/application.rs diff --git a/crates/owlry-core/src/providers/calculator.rs b/crates/owlry/src/providers/calculator.rs similarity index 100% rename from crates/owlry-core/src/providers/calculator.rs rename to crates/owlry/src/providers/calculator.rs diff --git a/crates/owlry-core/src/providers/command.rs b/crates/owlry/src/providers/command.rs similarity index 100% rename from crates/owlry-core/src/providers/command.rs rename to crates/owlry/src/providers/command.rs diff --git a/crates/owlry-core/src/providers/converter/currency.rs b/crates/owlry/src/providers/converter/currency.rs similarity index 100% rename from crates/owlry-core/src/providers/converter/currency.rs rename to crates/owlry/src/providers/converter/currency.rs diff --git a/crates/owlry-core/src/providers/converter/mod.rs b/crates/owlry/src/providers/converter/mod.rs similarity index 100% rename from crates/owlry-core/src/providers/converter/mod.rs rename to crates/owlry/src/providers/converter/mod.rs diff --git a/crates/owlry-core/src/providers/converter/parser.rs b/crates/owlry/src/providers/converter/parser.rs similarity index 100% rename from crates/owlry-core/src/providers/converter/parser.rs rename to crates/owlry/src/providers/converter/parser.rs diff --git a/crates/owlry-core/src/providers/converter/units.rs b/crates/owlry/src/providers/converter/units.rs similarity index 100% rename from crates/owlry-core/src/providers/converter/units.rs rename to crates/owlry/src/providers/converter/units.rs diff --git a/crates/owlry/src/providers/dmenu.rs b/crates/owlry/src/providers/dmenu.rs index 12eccf9..e148520 100644 --- a/crates/owlry/src/providers/dmenu.rs +++ b/crates/owlry/src/providers/dmenu.rs @@ -1,5 +1,5 @@ use log::debug; -use owlry_core::providers::{ItemSource, LaunchItem, Provider, ProviderType}; +use crate::providers::{ItemSource, LaunchItem, Provider, ProviderType}; use std::io::{self, BufRead}; /// Provider for dmenu-style input from stdin diff --git a/crates/owlry/src/providers/mod.rs b/crates/owlry/src/providers/mod.rs index bbb7ad5..4e17b19 100644 --- a/crates/owlry/src/providers/mod.rs +++ b/crates/owlry/src/providers/mod.rs @@ -1,2 +1,1098 @@ +// Core providers (compiled in) +mod application; +mod command; pub mod dmenu; +pub(crate) mod calculator; +pub(crate) mod converter; +pub(crate) mod system; + +// Re-exports for core providers +pub use application::ApplicationProvider; +pub use command::CommandProvider; pub use dmenu::DmenuProvider; + +use chrono::Utc; +use fuzzy_matcher::FuzzyMatcher; +use fuzzy_matcher::skim::SkimMatcherV2; +use log::info; + +#[cfg(feature = "dev-logging")] +use log::debug; + +use std::sync::{Arc, RwLock}; + +use crate::config::Config; +use crate::data::FrecencyStore; + +/// Where a provider sits in the UI. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderPosition { + /// Normal results list. + Normal, + /// Widget — rendered at the top of the results, outside the normal flow. + Widget, +} + +impl ProviderPosition { + pub fn as_str(&self) -> &'static str { + match self { + ProviderPosition::Normal => "normal", + ProviderPosition::Widget => "widget", + } + } +} + +/// Metadata descriptor for an available provider (used by IPC/daemon API) +#[derive(Debug, Clone)] +pub struct ProviderDescriptor { + pub id: String, + pub name: String, + pub prefix: Option, + pub icon: String, + pub position: String, + pub tab_label: Option, + pub search_noun: Option, +} + +/// Trust level of a [`LaunchItem`]'s command, used to gate `sh -c` execution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ItemSource { + /// Built-in provider compiled into the binary (trusted). + Core, + /// Script-defined provider (Lua, in Phase 3+) — user-installed, untrusted. + ScriptPlugin, +} + +impl ItemSource { + pub fn as_str(&self) -> &'static str { + match self { + ItemSource::Core => "core", + ItemSource::ScriptPlugin => "script_plugin", + } + } +} + +impl std::str::FromStr for ItemSource { + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "script_plugin" => Ok(ItemSource::ScriptPlugin), + _ => Ok(ItemSource::Core), + } + } +} + +/// Represents a single searchable/launchable item +#[derive(Debug, Clone)] +pub struct LaunchItem { + #[allow(dead_code)] + pub id: String, + pub name: String, + pub description: Option, + pub icon: Option, + pub provider: ProviderType, + pub command: String, + pub terminal: bool, + /// Tags/categories for filtering (e.g., from .desktop Categories) + pub tags: Vec, + /// Trust level — gates `sh -c` execution for script plugin items. + pub source: ItemSource, +} + +/// Provider type identifier for filtering and badge display. +/// +/// - `Application`, `Command`, `Dmenu`: built-in core providers +/// - `Plugin(type_id)`: any other provider (compiled-in feature module or +/// future Lua-registered provider). The `type_id` is the provider's +/// stable identifier (e.g. `"bookmarks"`, `"uuctl"`, `"calc"`). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ProviderType { + Application, + Command, + Dmenu, + Plugin(String), +} + +impl std::str::FromStr for ProviderType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "app" | "apps" | "application" | "applications" => Ok(ProviderType::Application), + "cmd" | "cmds" | "command" | "commands" => Ok(ProviderType::Command), + "dmenu" => Ok(ProviderType::Dmenu), + other => Ok(ProviderType::Plugin(other.to_string())), + } + } +} + +impl std::fmt::Display for ProviderType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProviderType::Application => write!(f, "app"), + ProviderType::Command => write!(f, "cmd"), + ProviderType::Dmenu => write!(f, "dmenu"), + ProviderType::Plugin(type_id) => write!(f, "{}", type_id), + } + } +} + +/// Trait for all search providers. +/// +/// Static providers hold a refreshable item cache. Submenu and action support +/// are optional methods — implement them only if the provider produces +/// `SUBMENU:` items or handles plugin-defined action commands. +pub trait Provider: Send + Sync { + #[allow(dead_code)] + fn name(&self) -> &str; + fn provider_type(&self) -> ProviderType; + fn refresh(&mut self); + fn items(&self) -> &[LaunchItem]; + + /// Short label for UI tab button (e.g., "Shutdown"). None = use default. + fn tab_label(&self) -> Option<&str> { + None + } + /// Noun for search placeholder (e.g., "shutdown actions"). None = use default. + fn search_noun(&self) -> Option<&str> { + None + } + /// Optional search prefix (e.g. ":bm"). None = no prefix. + fn prefix(&self) -> Option<&str> { + None + } + /// Icon name (XDG icon theme). + fn icon(&self) -> &str { + "application-x-addon" + } + /// UI placement. + fn position(&self) -> ProviderPosition { + ProviderPosition::Normal + } + /// Priority hint for ordering. + fn priority(&self) -> u32 { + 0 + } + + /// Generate submenu actions for an item whose command starts with `SUBMENU:`. + /// `data` is everything after the type_id prefix. + /// Default: no submenu support. + fn submenu_actions(&self, _data: &str) -> Vec { + Vec::new() + } + + /// Handle a plugin-defined action command (e.g. `"POMODORO:start"`). + /// Returns true if the command was handled. + /// Default: no action support. + fn execute_action(&self, _command: &str) -> bool { + false + } +} + +/// Trait for built-in providers that produce results per-keystroke. +/// Unlike static `Provider`s which cache items via `refresh()`/`items()`, +/// dynamic providers generate results on every query. +pub trait DynamicProvider: Send + Sync { + #[allow(dead_code)] + fn name(&self) -> &str; + fn provider_type(&self) -> ProviderType; + fn query(&self, query: &str) -> Vec; + 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. +pub struct ProviderManager { + /// Static providers (apps, commands, systemd, etc.). + providers: Vec>, + /// Dynamic providers (calculator, converter, websearch, filesearch). + /// Queried per-keystroke, not cached. + builtin_dynamic: Vec>, + /// Fuzzy matcher for search. + matcher: SkimMatcherV2, +} + +impl ProviderManager { + /// Create a new ProviderManager from a pre-built list of providers. + /// + /// Used by tests and by the dmenu-mode client. The daemon uses + /// [`Self::new_with_config`] which builds the provider set from config. + pub fn new( + core_providers: Vec>, + dynamic: Vec>, + ) -> Self { + let mut manager = Self { + providers: core_providers, + builtin_dynamic: dynamic, + matcher: SkimMatcherV2::default(), + }; + manager.refresh_all(); + manager + } + + /// Build a ProviderManager for the daemon, sourcing enabled providers from config. + /// + /// Only built-in / compiled-in providers are registered here. Future Lua-defined + /// providers (Phase 3+) are added via [`Self::add_provider`] after construction. + pub fn new_with_config(config: Arc>) -> Self { + let (calc_enabled, conv_enabled, sys_enabled) = match config.read() { + Ok(cfg) => ( + cfg.providers.calculator, + cfg.providers.converter, + cfg.providers.system, + ), + Err(_) => { + log::warn!("Config lock poisoned during provider init; using defaults"); + (true, true, true) + } + }; + + let mut core_providers: Vec> = vec![ + Box::new(ApplicationProvider::new()), + Box::new(CommandProvider::new()), + ]; + + if sys_enabled { + core_providers.push(Box::new(system::SystemProvider::new())); + info!("Registered built-in system provider"); + } + + let mut builtin_dynamic: Vec> = Vec::new(); + if calc_enabled { + builtin_dynamic.push(Box::new(calculator::CalculatorProvider)); + info!("Registered built-in calculator provider"); + } + if conv_enabled { + builtin_dynamic.push(Box::new(converter::ConverterProvider::new())); + info!("Registered built-in converter provider"); + } + + Self::new(core_providers, builtin_dynamic) + } + + #[allow(dead_code)] + pub fn is_dmenu_mode(&self) -> bool { + self.providers + .iter() + .any(|p| p.provider_type() == ProviderType::Dmenu) + } + + pub fn refresh_all(&mut self) { + for provider in &mut self.providers { + provider.refresh(); + info!( + "Provider '{}' loaded {} items", + provider.name(), + provider.items().len() + ); + } + // Dynamic providers don't need refresh (they query on demand). + } + + /// Register an additional provider at runtime. + /// + /// Used by Phase 3+ Lua config to add user-defined providers after the + /// daemon has booted. The provider's `refresh()` is called immediately. + #[allow(dead_code)] + pub fn add_provider(&mut self, mut provider: Box) { + provider.refresh(); + info!("Registered provider: {}", provider.name()); + self.providers.push(provider); + } + + /// Execute a plugin-defined action command. + /// + /// Command format: `PLUGIN_ID:action_data` (e.g. `"POMODORO:start"`). + /// Returns true if a provider handled the command. + pub fn execute_plugin_action(&self, command: &str) -> bool { + for provider in &self.providers { + if provider.execute_action(command) { + return true; + } + } + for provider in &self.builtin_dynamic { + if provider.execute_action(command) { + return true; + } + } + false + } + + #[allow(dead_code)] + pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> { + if query.is_empty() { + return self + .providers + .iter() + .flat_map(|p| p.items().iter().cloned()) + .take(max_results) + .map(|item| (item, 0)) + .collect(); + } + + let mut results: Vec<(LaunchItem, i64)> = self + .providers + .iter() + .flat_map(|p| p.items().iter()) + .filter_map(|item| { + let name_score = self.matcher.fuzzy_match(&item.name, query); + let desc_score = item + .description + .as_ref() + .and_then(|d| self.matcher.fuzzy_match(d, query)); + let score = match (name_score, desc_score) { + (Some(n), Some(d)) => Some(n.max(d)), + (Some(n), None) => Some(n), + (None, Some(d)) => Some(d / 2), + (None, None) => None, + }; + score.map(|s| (item.clone(), s)) + }) + .collect(); + + results.sort_by(|a, b| b.1.cmp(&a.1)); + results.truncate(max_results); + results + } + + /// Search with provider filtering. + #[allow(dead_code)] + pub fn search_filtered( + &self, + query: &str, + max_results: usize, + filter: &crate::filter::ProviderFilter, + tag_filter: Option<&str>, + ) -> Vec<(LaunchItem, i64)> { + let all_items = self + .providers + .iter() + .filter(|p| filter.is_active(p.provider_type())) + .flat_map(|p| p.items().iter().cloned()) + .filter(|item| tag_filter.is_none_or(|t| item.tags.iter().any(|it| it == t))); + + if query.is_empty() { + return all_items.take(max_results).map(|item| (item, 0)).collect(); + } + + let mut results: Vec<(LaunchItem, i64)> = all_items + .filter_map(|item| { + let name_score = self.matcher.fuzzy_match(&item.name, query); + let desc_score = item + .description + .as_ref() + .and_then(|d| self.matcher.fuzzy_match(d, query)); + let score = match (name_score, desc_score) { + (Some(n), Some(d)) => Some(n.max(d)), + (Some(n), None) => Some(n), + (None, Some(d)) => Some(d / 2), + (None, None) => None, + }; + score.map(|s| (item, s)) + }) + .collect(); + + results.sort_by(|a, b| b.1.cmp(&a.1)); + results.truncate(max_results); + results + } + + /// Search with frecency boosting, dynamic providers, and tag filtering. + pub fn search_with_frecency( + &self, + query: &str, + max_results: usize, + filter: &crate::filter::ProviderFilter, + frecency: &FrecencyStore, + frecency_weight: f64, + tag_filter: Option<&str>, + ) -> Vec<(LaunchItem, i64)> { + #[cfg(feature = "dev-logging")] + debug!( + "[Search] query={:?}, max={}, frecency_weight={}", + query, max_results, frecency_weight + ); + + let now = Utc::now(); + let mut results: Vec<(LaunchItem, i64)> = Vec::new(); + + // Widget providers contribute on empty query (no prefix active). + if filter.active_prefix().is_none() && query.is_empty() { + for provider in &self.providers { + if provider.position() != ProviderPosition::Widget { + continue; + } + let base_score = provider.priority() as i64; + for (idx, item) in provider.items().iter().enumerate() { + results.push((item.clone(), base_score - idx as i64)); + } + } + } + + // Dynamic providers (calculator, converter, etc.) — only when there's a query. + if !query.is_empty() { + for provider in &self.builtin_dynamic { + if !filter.is_active(provider.provider_type()) { + continue; + } + let dynamic_results = provider.query(query); + let base_score = provider.priority() as i64; + let grouping_bonus: i64 = match provider.provider_type() { + ProviderType::Plugin(ref id) if matches!(id.as_str(), "calc" | "conv") => { + 10_000 + } + _ => 0, + }; + for (idx, item) in dynamic_results.into_iter().enumerate() { + results.push((item, base_score + grouping_bonus - idx as i64)); + } + } + } + + // Empty query (after widgets) — frecency-sorted items. + if query.is_empty() { + let mut scored_refs: Vec<(&LaunchItem, i64)> = self + .providers + .iter() + .filter(|p| p.position() != ProviderPosition::Widget) + .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; + } + + // Regular search with frecency boost and tag matching. + let score_item = |item: &LaunchItem| -> Option { + if let Some(tag) = tag_filter + && !item.tags.iter().any(|t| t.to_lowercase().contains(tag)) + { + return None; + } + let name_score = self.matcher.fuzzy_match(&item.name, query); + let desc_score = item + .description + .as_ref() + .and_then(|d| self.matcher.fuzzy_match(d, query)); + let tag_score = item + .tags + .iter() + .filter_map(|t| self.matcher.fuzzy_match(t, query)) + .max() + .map(|s| s / 3); + + let base_score = match (name_score, desc_score, tag_score) { + (Some(n), Some(d), Some(t)) => Some(n.max(d).max(t)), + (Some(n), Some(d), None) => Some(n.max(d)), + (Some(n), None, Some(t)) => Some(n.max(t)), + (Some(n), None, None) => Some(n), + (None, Some(d), Some(t)) => Some((d / 2).max(t)), + (None, Some(d), None) => Some(d / 2), + (None, None, Some(t)) => Some(t), + (None, None, None) => None, + }; + + 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 + }) + }; + + let mut scored_refs: Vec<(&LaunchItem, i64)> = Vec::new(); + for provider in &self.providers { + if provider.position() == ProviderPosition::Widget { + continue; + } + 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)); + } + } + } + + 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); + + #[cfg(feature = "dev-logging")] + { + debug!("[Search] Returning {} results", results.len()); + for (i, (item, score)) in results.iter().take(5).enumerate() { + debug!( + "[Search] #{}: {} (score={}, provider={:?})", + i + 1, + item.name, + score, + item.provider + ); + } + if results.len() > 5 { + debug!("[Search] ... and {} more", results.len() - 5); + } + } + + results + } + + /// Get all available provider types (for UI tabs). + #[allow(dead_code)] + pub fn available_provider_types(&self) -> Vec { + self.providers.iter().map(|p| p.provider_type()).collect() + } + + /// Get descriptors for all registered providers. + /// + /// Used by the IPC server to report what providers are available to clients. + pub fn available_providers(&self) -> Vec { + let mut descs = Vec::new(); + + for provider in &self.providers { + let (id, default_prefix, default_icon) = match provider.provider_type() { + ProviderType::Application => ( + "app".to_string(), + Some(":app".to_string()), + "application-x-executable", + ), + ProviderType::Command => ( + "cmd".to_string(), + Some(":cmd".to_string()), + "utilities-terminal", + ), + ProviderType::Dmenu => ("dmenu".to_string(), None, "view-list-symbolic"), + ProviderType::Plugin(type_id) => (type_id, None, "application-x-addon"), + }; + descs.push(ProviderDescriptor { + id, + name: provider.name().to_string(), + prefix: provider.prefix().map(String::from).or(default_prefix), + icon: { + let trait_icon = provider.icon(); + if trait_icon == "application-x-addon" { + default_icon.to_string() + } else { + trait_icon.to_string() + } + }, + position: provider.position().as_str().to_string(), + tab_label: provider.tab_label().map(String::from), + search_noun: provider.search_noun().map(String::from), + }); + } + + descs + } + + /// Refresh a specific provider by its type_id. + pub fn refresh_provider(&mut self, provider_id: &str) { + for provider in &mut self.providers { + let matches = match provider.provider_type() { + ProviderType::Application => provider_id == "app", + ProviderType::Command => provider_id == "cmd", + ProviderType::Dmenu => provider_id == "dmenu", + ProviderType::Plugin(ref id) => provider_id == id, + }; + if matches { + provider.refresh(); + info!("Refreshed provider '{}'", provider.name()); + return; + } + } + info!("Provider '{}' not found for refresh", provider_id); + } + + /// Query a provider for submenu actions. + /// + /// Called when a user selects a `SUBMENU:plugin_id:data` item. The provider's + /// `submenu_actions(data)` is invoked to produce the action list. + pub fn query_submenu_actions( + &self, + plugin_id: &str, + data: &str, + display_name: &str, + ) -> Option<(String, Vec)> { + #[cfg(feature = "dev-logging")] + debug!("[Submenu] Querying provider '{}' with data: {}", plugin_id, data); + + for provider in &self.providers { + let matches = match provider.provider_type() { + ProviderType::Plugin(ref id) => id == plugin_id, + _ => false, + }; + if matches { + let actions = provider.submenu_actions(data); + if !actions.is_empty() { + return Some((display_name.to_string(), actions)); + } + } + } + + #[cfg(feature = "dev-logging")] + debug!("[Submenu] No submenu actions for provider '{}'", plugin_id); + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Minimal mock provider for testing ProviderManager + struct MockProvider { + name: String, + provider_type: ProviderType, + items: Vec, + refresh_count: usize, + } + + impl MockProvider { + fn new(name: &str, provider_type: ProviderType) -> Self { + Self { + name: name.to_string(), + provider_type, + items: Vec::new(), + refresh_count: 0, + } + } + + fn with_items(mut self, items: Vec) -> Self { + self.items = items; + self + } + } + + impl Provider for MockProvider { + fn name(&self) -> &str { + &self.name + } + + fn provider_type(&self) -> ProviderType { + self.provider_type.clone() + } + + fn refresh(&mut self) { + self.refresh_count += 1; + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } + } + + fn make_item(id: &str, name: &str, provider: ProviderType) -> LaunchItem { + LaunchItem { + id: id.to_string(), + name: name.to_string(), + description: None, + icon: None, + provider, + command: format!("run-{}", id), + terminal: false, + tags: Vec::new(), + source: ItemSource::Core, + } + } + + #[test] + fn test_available_providers_core_only() { + let providers: Vec> = vec![ + Box::new(MockProvider::new("Applications", ProviderType::Application)), + Box::new(MockProvider::new("Commands", ProviderType::Command)), + ]; + let pm = ProviderManager::new(providers, Vec::new()); + let descs = pm.available_providers(); + assert_eq!(descs.len(), 2); + assert_eq!(descs[0].id, "app"); + assert_eq!(descs[0].name, "Applications"); + assert_eq!(descs[0].prefix, Some(":app".to_string())); + assert_eq!(descs[0].icon, "application-x-executable"); + assert_eq!(descs[0].position, "normal"); + assert_eq!(descs[1].id, "cmd"); + assert_eq!(descs[1].name, "Commands"); + } + + #[test] + fn test_available_providers_dmenu() { + let providers: Vec> = + vec![Box::new(MockProvider::new("dmenu", ProviderType::Dmenu))]; + let pm = ProviderManager::new(providers, Vec::new()); + let descs = pm.available_providers(); + assert_eq!(descs.len(), 1); + assert_eq!(descs[0].id, "dmenu"); + assert!(descs[0].prefix.is_none()); + } + + #[test] + fn test_available_provider_types() { + let providers: Vec> = vec![ + Box::new(MockProvider::new("Applications", ProviderType::Application)), + Box::new(MockProvider::new("Commands", ProviderType::Command)), + ]; + let pm = ProviderManager::new(providers, Vec::new()); + let types = pm.available_provider_types(); + assert_eq!(types.len(), 2); + assert!(types.contains(&ProviderType::Application)); + assert!(types.contains(&ProviderType::Command)); + } + + #[test] + fn test_refresh_provider_core() { + let app = MockProvider::new("Applications", ProviderType::Application); + let cmd = MockProvider::new("Commands", ProviderType::Command); + let providers: Vec> = vec![Box::new(app), Box::new(cmd)]; + let mut pm = ProviderManager::new(providers, Vec::new()); + + pm.refresh_provider("app"); + pm.refresh_provider("cmd"); + // Just verifying it doesn't panic. + } + + #[test] + fn test_refresh_provider_unknown_does_not_panic() { + let providers: Vec> = vec![Box::new(MockProvider::new( + "Applications", + ProviderType::Application, + ))]; + let mut pm = ProviderManager::new(providers, Vec::new()); + pm.refresh_provider("nonexistent"); + } + + #[test] + fn test_search_with_core_providers() { + let items = vec![ + make_item("firefox", "Firefox", ProviderType::Application), + make_item("vim", "Vim", ProviderType::Application), + ]; + let provider = + MockProvider::new("Applications", ProviderType::Application).with_items(items); + let providers: Vec> = vec![Box::new(provider)]; + let pm = ProviderManager::new(providers, Vec::new()); + + let results = pm.search("fire", 10); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0.name, "Firefox"); + } + + // ========================================================================= + // Tests for behavior introduced in the v2 C-ABI demolition (commit ae4a903) + // ========================================================================= + + /// Provider impl that overrides every trait method to verify customization + /// flows through `ProviderManager::available_providers()` and submenu/action + /// dispatch. + struct RichMockProvider { + type_id: String, + items: Vec, + submenu: Vec, + action_handled_for: Option, + } + + impl Provider for RichMockProvider { + fn name(&self) -> &str { + "Rich" + } + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(self.type_id.clone()) + } + fn refresh(&mut self) {} + fn items(&self) -> &[LaunchItem] { + &self.items + } + fn prefix(&self) -> Option<&str> { + Some(":rich") + } + fn icon(&self) -> &str { + "rich-icon" + } + fn position(&self) -> ProviderPosition { + ProviderPosition::Widget + } + fn priority(&self) -> u32 { + 42 + } + fn tab_label(&self) -> Option<&str> { + Some("Rich") + } + fn search_noun(&self) -> Option<&str> { + Some("rich things") + } + fn submenu_actions(&self, _data: &str) -> Vec { + self.submenu.clone() + } + fn execute_action(&self, command: &str) -> bool { + self.action_handled_for + .as_ref() + .map(|prefix| command.starts_with(prefix.as_str())) + .unwrap_or(false) + } + } + + #[test] + fn provider_trait_default_methods_return_documented_values() { + // MockProvider does not override any optional method; defaults must hold. + let p = MockProvider::new("M", ProviderType::Application); + assert_eq!(p.prefix(), None); + assert_eq!(p.icon(), "application-x-addon"); + assert_eq!(p.position(), ProviderPosition::Normal); + assert_eq!(p.priority(), 0); + assert_eq!(p.tab_label(), None); + assert_eq!(p.search_noun(), None); + assert!(p.submenu_actions("anything").is_empty()); + assert!(!p.execute_action("MOCK:anything")); + } + + #[test] + fn provider_position_as_str_matches_ipc_strings() { + assert_eq!(ProviderPosition::Normal.as_str(), "normal"); + assert_eq!(ProviderPosition::Widget.as_str(), "widget"); + } + + #[test] + fn provider_type_from_str_accepts_plural_aliases() { + // After the demolition, FromStr accepts both singular and plural aliases. + use std::str::FromStr; + assert_eq!(ProviderType::from_str("app").unwrap(), ProviderType::Application); + assert_eq!(ProviderType::from_str("apps").unwrap(), ProviderType::Application); + assert_eq!( + ProviderType::from_str("application").unwrap(), + ProviderType::Application + ); + assert_eq!( + ProviderType::from_str("applications").unwrap(), + ProviderType::Application + ); + assert_eq!(ProviderType::from_str("cmd").unwrap(), ProviderType::Command); + assert_eq!(ProviderType::from_str("cmds").unwrap(), ProviderType::Command); + assert_eq!( + ProviderType::from_str("command").unwrap(), + ProviderType::Command + ); + assert_eq!( + ProviderType::from_str("commands").unwrap(), + ProviderType::Command + ); + assert_eq!(ProviderType::from_str("dmenu").unwrap(), ProviderType::Dmenu); + // Anything unknown becomes Plugin(s) — preserves user-defined provider IDs. + assert_eq!( + ProviderType::from_str("bookmarks").unwrap(), + ProviderType::Plugin("bookmarks".into()) + ); + assert_eq!( + ProviderType::from_str("uuctl").unwrap(), + ProviderType::Plugin("uuctl".into()) + ); + } + + #[test] + fn item_source_from_str_maps_unknown_to_core() { + // NativePlugin variant is gone; unknown strings (including the old + // "native_plugin" tag from pre-2.0 daemons) decode to Core. + use std::str::FromStr; + assert_eq!(ItemSource::from_str("core"), Ok(ItemSource::Core)); + assert_eq!( + ItemSource::from_str("script_plugin"), + Ok(ItemSource::ScriptPlugin) + ); + assert_eq!(ItemSource::from_str("native_plugin"), Ok(ItemSource::Core)); + assert_eq!(ItemSource::from_str(""), Ok(ItemSource::Core)); + assert_eq!(ItemSource::from_str("anything-else"), Ok(ItemSource::Core)); + } + + #[test] + fn item_source_as_str_only_emits_supported_variants() { + assert_eq!(ItemSource::Core.as_str(), "core"); + assert_eq!(ItemSource::ScriptPlugin.as_str(), "script_plugin"); + } + + #[test] + fn add_provider_refreshes_and_appends() { + let mut pm = ProviderManager::new(Vec::new(), Vec::new()); + assert_eq!(pm.available_provider_types().len(), 0); + + let prov = MockProvider::new("Late", ProviderType::Plugin("late".into())); + pm.add_provider(Box::new(prov)); + + let types = pm.available_provider_types(); + assert_eq!(types.len(), 1); + assert_eq!(types[0], ProviderType::Plugin("late".into())); + // add_provider must call refresh() — checked indirectly by the impl + // contract; refresh_count on MockProvider was bumped, but we can't + // peek through the Box. The public observable is that items() + // is callable without panic, which we exercise here. + assert!(pm.available_providers()[0].id == "late"); + } + + #[test] + fn available_providers_uses_trait_overrides_for_plugin_type() { + let prov = RichMockProvider { + type_id: "rich".into(), + items: Vec::new(), + submenu: Vec::new(), + action_handled_for: None, + }; + let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); + let descs = pm.available_providers(); + assert_eq!(descs.len(), 1); + let d = &descs[0]; + assert_eq!(d.id, "rich"); + assert_eq!(d.prefix.as_deref(), Some(":rich")); + assert_eq!(d.icon, "rich-icon"); + assert_eq!(d.position, "widget"); + assert_eq!(d.tab_label.as_deref(), Some("Rich")); + assert_eq!(d.search_noun.as_deref(), Some("rich things")); + } + + #[test] + fn query_submenu_actions_returns_some_when_provider_matches_and_has_actions() { + let prov = RichMockProvider { + type_id: "uuctl".into(), + items: Vec::new(), + submenu: vec![make_item( + "start", + "Start service", + ProviderType::Plugin("uuctl".into()), + )], + action_handled_for: None, + }; + let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); + + let result = pm.query_submenu_actions("uuctl", "foo.service:true", "foo"); + assert!(result.is_some(), "expected Some when provider matches and returns actions"); + let (display, actions) = result.unwrap(); + assert_eq!(display, "foo"); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].name, "Start service"); + } + + #[test] + fn query_submenu_actions_returns_none_when_no_provider_matches() { + let prov = RichMockProvider { + type_id: "uuctl".into(), + items: Vec::new(), + submenu: vec![make_item("x", "x", ProviderType::Plugin("uuctl".into()))], + action_handled_for: None, + }; + let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); + assert!(pm.query_submenu_actions("does-not-exist", "data", "name").is_none()); + } + + #[test] + fn query_submenu_actions_returns_none_when_provider_returns_empty_actions() { + let prov = RichMockProvider { + type_id: "uuctl".into(), + items: Vec::new(), + submenu: Vec::new(), // matching provider but no actions + action_handled_for: None, + }; + let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); + assert!(pm.query_submenu_actions("uuctl", "data", "name").is_none()); + } + + #[test] + fn execute_plugin_action_returns_true_when_static_provider_handles() { + let prov = RichMockProvider { + type_id: "pomodoro".into(), + items: Vec::new(), + submenu: Vec::new(), + action_handled_for: Some("POMODORO:".into()), + }; + let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); + assert!(pm.execute_plugin_action("POMODORO:start")); + } + + #[test] + fn execute_plugin_action_returns_true_when_dynamic_provider_handles() { + struct DynStub { + handles: String, + } + impl DynamicProvider for DynStub { + fn name(&self) -> &str { + "dyn" + } + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin("dyn".into()) + } + fn query(&self, _q: &str) -> Vec { + Vec::new() + } + fn priority(&self) -> u32 { + 0 + } + fn execute_action(&self, command: &str) -> bool { + command.starts_with(&self.handles) + } + } + + let pm = ProviderManager::new( + Vec::new(), + vec![Box::new(DynStub { + handles: "DYN:".into(), + })], + ); + assert!(pm.execute_plugin_action("DYN:thing")); + } + + #[test] + fn execute_plugin_action_returns_false_when_nothing_handles() { + let prov = RichMockProvider { + type_id: "x".into(), + items: Vec::new(), + submenu: Vec::new(), + action_handled_for: Some("X:".into()), + }; + let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); + assert!(!pm.execute_plugin_action("UNRELATED:command")); + } + + #[test] + fn provider_manager_new_with_no_providers_does_not_panic() { + // Regression guard: the daemon may be configured to disable every + // provider; construction must still succeed. + let pm = ProviderManager::new(Vec::new(), Vec::new()); + assert_eq!(pm.available_providers().len(), 0); + assert!(!pm.execute_plugin_action("ANYTHING:foo")); + assert!(pm.query_submenu_actions("anything", "data", "n").is_none()); + } +} diff --git a/crates/owlry-core/src/providers/system.rs b/crates/owlry/src/providers/system.rs similarity index 100% rename from crates/owlry-core/src/providers/system.rs rename to crates/owlry/src/providers/system.rs diff --git a/crates/owlry-core/src/server.rs b/crates/owlry/src/server.rs similarity index 100% rename from crates/owlry-core/src/server.rs rename to crates/owlry/src/server.rs diff --git a/crates/owlry/src/theme.rs b/crates/owlry/src/theme.rs index f0b11e8..b3f4db6 100644 --- a/crates/owlry/src/theme.rs +++ b/crates/owlry/src/theme.rs @@ -1,4 +1,4 @@ -use owlry_core::config::AppearanceConfig; +use crate::config::AppearanceConfig; /// Generate CSS with :root variables from config settings pub fn generate_variables_css(config: &AppearanceConfig) -> String { diff --git a/crates/owlry/src/ui/main_window.rs b/crates/owlry/src/ui/main_window.rs index 33a3ed4..291edce 100644 --- a/crates/owlry/src/ui/main_window.rs +++ b/crates/owlry/src/ui/main_window.rs @@ -9,10 +9,10 @@ use gtk4::{ ListBoxRow, Orientation, ScrolledWindow, SelectionMode, ToggleButton, }; use log::info; -use owlry_core::config::Config; -use owlry_core::filter::ProviderFilter; -use owlry_core::ipc::ProviderDesc; -use owlry_core::providers::{ItemSource, LaunchItem, ProviderType}; +use crate::config::Config; +use crate::filter::ProviderFilter; +use crate::ipc::ProviderDesc; +use crate::providers::{ItemSource, LaunchItem, ProviderType}; #[cfg(feature = "dev-logging")] use log::debug; @@ -382,7 +382,7 @@ impl MainWindow { /// Build hints string for the status bar based on enabled built-in providers. /// Plugin trigger hints (? web, / files, etc.) are not included here since /// plugin availability is not tracked in ProvidersConfig. - fn build_hints(config: &owlry_core::config::ProvidersConfig) -> String { + fn build_hints(config: &crate::config::ProvidersConfig) -> String { let mut parts: Vec = vec![ "Tab: cycle".to_string(), "↑↓: nav".to_string(), @@ -1366,7 +1366,7 @@ impl MainWindow { item.name, cmd ); log::warn!("{}", msg); - owlry_core::notify::notify("Command blocked", &msg); + crate::notify::notify("Command blocked", &msg); return; } } @@ -1375,7 +1375,7 @@ impl MainWindow { if item.command.is_empty() && !matches!(item.provider, ProviderType::Application) { let msg = format!("Item '{}' has no command; cannot launch", item.name); log::warn!("{}", msg); - owlry_core::notify::notify("Launch failed", &msg); + crate::notify::notify("Launch failed", &msg); return; } @@ -1397,7 +1397,7 @@ impl MainWindow { if let Err(e) = result { let msg = format!("Failed to launch '{}': {}", item.name, e); log::error!("{}", msg); - owlry_core::notify::notify("Launch failed", &msg); + crate::notify::notify("Launch failed", &msg); } } @@ -1418,7 +1418,7 @@ impl MainWindow { if !Path::new(desktop_path).exists() { let msg = format!("Desktop file not found: {}", desktop_path); log::error!("{}", msg); - owlry_core::notify::notify("Launch failed", &msg); + crate::notify::notify("Launch failed", &msg); return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg)); } @@ -1435,7 +1435,7 @@ impl MainWindow { if !uwsm_available { let msg = "uwsm is enabled in config but not installed"; log::error!("{}", msg); - owlry_core::notify::notify("Launch failed", msg); + crate::notify::notify("Launch failed", msg); return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg)); } diff --git a/crates/owlry/src/ui/provider_meta.rs b/crates/owlry/src/ui/provider_meta.rs index 60ffee5..2f4c517 100644 --- a/crates/owlry/src/ui/provider_meta.rs +++ b/crates/owlry/src/ui/provider_meta.rs @@ -1,5 +1,5 @@ -use owlry_core::ipc::ProviderDesc; -use owlry_core::providers::ProviderType; +use crate::ipc::ProviderDesc; +use crate::providers::ProviderType; /// Display metadata for a provider. pub struct ProviderMeta { diff --git a/crates/owlry/src/ui/result_row.rs b/crates/owlry/src/ui/result_row.rs index 866f3e3..ab4b796 100644 --- a/crates/owlry/src/ui/result_row.rs +++ b/crates/owlry/src/ui/result_row.rs @@ -1,6 +1,6 @@ use gtk4::prelude::*; use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget}; -use owlry_core::providers::{LaunchItem, ProviderType}; +use crate::providers::{LaunchItem, ProviderType}; #[allow(dead_code)] pub struct ResultRow { @@ -107,13 +107,13 @@ impl ResultRow { } else { // Default icon based on provider type (only core types, plugins should provide icons) let default_icon = match &item.provider { - owlry_core::providers::ProviderType::Application => { + crate::providers::ProviderType::Application => { "application-x-executable-symbolic" } - owlry_core::providers::ProviderType::Command => "utilities-terminal-symbolic", - owlry_core::providers::ProviderType::Dmenu => "view-list-symbolic", + crate::providers::ProviderType::Command => "utilities-terminal-symbolic", + crate::providers::ProviderType::Dmenu => "view-list-symbolic", // Plugins should provide their own icon; fallback to generic addon icon - owlry_core::providers::ProviderType::Plugin(_) => "application-x-addon-symbolic", + crate::providers::ProviderType::Plugin(_) => "application-x-addon-symbolic", }; let img = Image::from_icon_name(default_icon); img.set_pixel_size(32); diff --git a/crates/owlry/src/ui/submenu.rs b/crates/owlry/src/ui/submenu.rs index 7a809ca..35512d3 100644 --- a/crates/owlry/src/ui/submenu.rs +++ b/crates/owlry/src/ui/submenu.rs @@ -46,7 +46,7 @@ //! } //! ``` -use owlry_core::providers::LaunchItem; +use crate::providers::LaunchItem; /// Parse a submenu command and extract plugin_id and data /// Returns (plugin_id, data) if command matches SUBMENU: format @@ -66,7 +66,7 @@ pub fn is_submenu_item(item: &LaunchItem) -> bool { #[cfg(test)] mod tests { use super::*; - use owlry_core::providers::{ItemSource, ProviderType}; + use crate::providers::{ItemSource, ProviderType}; #[test] fn test_parse_submenu_command() { diff --git a/crates/owlry-core/tests/ipc_test.rs b/crates/owlry/tests/ipc_test.rs similarity index 98% rename from crates/owlry-core/tests/ipc_test.rs rename to crates/owlry/tests/ipc_test.rs index ce3c62b..79fc10a 100644 --- a/crates/owlry-core/tests/ipc_test.rs +++ b/crates/owlry/tests/ipc_test.rs @@ -1,4 +1,4 @@ -use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem}; +use owlry::ipc::{ProviderDesc, Request, Response, ResultItem}; #[test] fn test_query_request_roundtrip() { diff --git a/crates/owlry-core/tests/server_test.rs b/crates/owlry/tests/server_test.rs similarity index 98% rename from crates/owlry-core/tests/server_test.rs rename to crates/owlry/tests/server_test.rs index b80ee13..4870ee0 100644 --- a/crates/owlry-core/tests/server_test.rs +++ b/crates/owlry/tests/server_test.rs @@ -2,8 +2,8 @@ use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; use std::thread; -use owlry_core::ipc::{Request, Response}; -use owlry_core::server::Server; +use owlry::ipc::{Request, Response}; +use owlry::server::Server; /// Helper: send a JSON request line and read the JSON response line. fn roundtrip(stream: &mut UnixStream, request: &Request) -> Response { diff --git a/docs/RESTRUCTURE-V2.md b/docs/RESTRUCTURE-V2.md index 8d74ddd..1c65885 100644 --- a/docs/RESTRUCTURE-V2.md +++ b/docs/RESTRUCTURE-V2.md @@ -451,7 +451,8 @@ This section captures in-progress state. Update freely as work proceeds. - `163e68a` — plan doc - `2fc976b` — D15–D21 resolutions - `ae4a903` — C-ABI demolition: tasks #3/#4/#5 done in one commit - - (next) — TDD characterization pass for demolition (Provider trait defaults, ProviderManager submenu/action dispatch, FromStr aliases, ItemSource simplification, CLI `-d` flag, plugin_list IPC rejection) + - `1d20754` — TDD characterization pass (+36 tests) + - (next) — Workspace collapse: owlry-core merged into owlry (task #2) - **Tasks done:** #1 inventory, #3 delete C-ABI, #4 delete Rune+Lua crates, #5 delete config_editor (scripts never lived in this repo) - **Tasks remaining (Phase 1):** #2 workspace collapse, #6 convert 8 plugins, #7 cargo features, #8 sys→power rename, #9 CLI subcommands, #10 auto-mode test, #11 final build+smoke - **Stray processes from inventory phase:** diff --git a/justfile b/justfile index af32b12..55625f6 100644 --- a/justfile +++ b/justfile @@ -8,25 +8,16 @@ default: build: cargo build --workspace -build-ui: - cargo build -p owlry - -build-daemon: - cargo build -p owlry-core - release: cargo build --workspace --release -release-daemon: - cargo build -p owlry-core --release - # === Run === run *ARGS: cargo run -p owlry -- {{ARGS}} run-daemon *ARGS: - cargo run -p owlry-core -- {{ARGS}} + cargo run -p owlry -- -d {{ARGS}} # === Quality === @@ -50,25 +41,14 @@ install-local: set -euo pipefail echo "Building release..." - cargo build -p owlry --release --no-default-features - cargo build -p owlry-core --release - cargo build -p owlry-lua -p owlry-rune --release + cargo build -p owlry --release - echo "Creating directories..." - sudo mkdir -p /usr/lib/owlry/plugins - sudo mkdir -p /usr/lib/owlry/runtimes - - echo "Installing binaries..." + echo "Installing binary..." sudo install -Dm755 target/release/owlry /usr/bin/owlry - sudo install -Dm755 target/release/owlryd /usr/bin/owlryd - - 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_rune.so ] && sudo install -Dm755 target/release/libowlry_rune.so /usr/lib/owlry/runtimes/librune.so echo "Installing systemd service files..." - [ -f systemd/owlryd.service ] && sudo install -Dm644 systemd/owlryd.service /usr/lib/systemd/user/owlryd.service - [ -f systemd/owlryd.socket ] && sudo install -Dm644 systemd/owlryd.socket /usr/lib/systemd/user/owlryd.socket + sudo install -Dm644 systemd/owlryd.service /usr/lib/systemd/user/owlryd.service + sudo install -Dm644 systemd/owlryd.socket /usr/lib/systemd/user/owlryd.socket echo "Done. Start daemon: systemctl --user enable --now owlryd.service" diff --git a/systemd/owlryd.service b/systemd/owlryd.service index 12a6454..b551eb6 100644 --- a/systemd/owlryd.service +++ b/systemd/owlryd.service @@ -5,7 +5,7 @@ After=graphical-session.target [Service] Type=simple -ExecStart=/usr/bin/owlryd +ExecStart=/usr/bin/owlry -d ExecReload=/bin/kill -HUP $MAINPID Restart=on-failure RestartSec=3 From eb8a65f1fdfcdd97763891af2191a12ef1de3253 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 02:10:09 +0200 Subject: [PATCH 06/23] feat(systemd): convert systemd provider from C-ABI to native Provider impl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of 7 plugin conversions (task #6). Establishes the conversion pattern: drop extern "C" vtable + PluginItem + opaque ProviderHandle in favor of a regular struct that impls the Provider trait. Submenu support comes via the new submenu_actions() trait method instead of the old '?SUBMENU:' string-encoded query convention. - providers/systemd.rs: new module, type_id 'uuctl' (CLI back-compat) - Provider impl with prefix(:uuctl), icon(system-run), tab_label(Units), search_noun(systemd units), and submenu_actions for service controls - ProviderManager::new_with_config registers it when config and feature both enabled - config.providers.systemd added (alias 'uuctl' for back-compat) - cargo feature 'systemd' (in default and full feature sets) - 8 unit tests: parse_systemctl_output, provider_type, submenu for active/inactive/empty data, terminal flag on status/journal, clean_display_name edge cases Issue #5 fixed locally — 'owlry -m uuctl' will return systemd units once the binary is rebuilt and installed. 186 tests pass. --- crates/owlry/Cargo.toml | 9 +- crates/owlry/src/config/mod.rs | 4 + crates/owlry/src/providers/mod.rs | 16 +- crates/owlry/src/providers/systemd.rs | 364 ++++++++++++++++++++++++++ 4 files changed, 390 insertions(+), 3 deletions(-) create mode 100644 crates/owlry/src/providers/systemd.rs diff --git a/crates/owlry/Cargo.toml b/crates/owlry/Cargo.toml index da5350a..fe0a3de 100644 --- a/crates/owlry/Cargo.toml +++ b/crates/owlry/Cargo.toml @@ -71,6 +71,13 @@ glib-build-tools = "0.20" tempfile = "3" [features] -default = [] +default = ["systemd"] + # Enable verbose debug logging (for development/testing builds) dev-logging = [] + +# Optional providers (compiled in when enabled). +# The AUR PKGBUILD builds with --features "full" to ship everything. +systemd = [] + +full = ["systemd"] diff --git a/crates/owlry/src/config/mod.rs b/crates/owlry/src/config/mod.rs index 28aff4d..f70622c 100644 --- a/crates/owlry/src/config/mod.rs +++ b/crates/owlry/src/config/mod.rs @@ -171,6 +171,9 @@ pub struct ProvidersConfig { /// Enable built-in system actions (shutdown, reboot, lock, etc.) #[serde(default = "default_true")] pub system: bool, + /// Enable systemd user units provider (alias: uuctl) + #[serde(default = "default_true", alias = "uuctl")] + pub systemd: bool, /// Enable frecency-based result ranking #[serde(default = "default_true")] pub frecency: bool, @@ -192,6 +195,7 @@ impl Default for ProvidersConfig { calculator: true, converter: true, system: true, + systemd: true, frecency: true, frecency_weight: 0.3, search_engine: "duckduckgo".to_string(), diff --git a/crates/owlry/src/providers/mod.rs b/crates/owlry/src/providers/mod.rs index 4e17b19..aa9549c 100644 --- a/crates/owlry/src/providers/mod.rs +++ b/crates/owlry/src/providers/mod.rs @@ -6,6 +6,10 @@ pub(crate) mod calculator; pub(crate) mod converter; pub(crate) mod system; +// Optional feature-gated providers +#[cfg(feature = "systemd")] +pub(crate) mod systemd; + // Re-exports for core providers pub use application::ApplicationProvider; pub use command::CommandProvider; @@ -239,17 +243,19 @@ impl ProviderManager { /// Only built-in / compiled-in providers are registered here. Future Lua-defined /// providers (Phase 3+) are added via [`Self::add_provider`] after construction. pub fn new_with_config(config: Arc>) -> Self { - let (calc_enabled, conv_enabled, sys_enabled) = match config.read() { + let (calc_enabled, conv_enabled, sys_enabled, systemd_enabled) = match config.read() { Ok(cfg) => ( cfg.providers.calculator, cfg.providers.converter, cfg.providers.system, + cfg.providers.systemd, ), Err(_) => { log::warn!("Config lock poisoned during provider init; using defaults"); - (true, true, true) + (true, true, true, true) } }; + let _ = systemd_enabled; // referenced only behind cfg(feature) let mut core_providers: Vec> = vec![ Box::new(ApplicationProvider::new()), @@ -261,6 +267,12 @@ impl ProviderManager { info!("Registered built-in system provider"); } + #[cfg(feature = "systemd")] + if systemd_enabled { + core_providers.push(Box::new(systemd::SystemdProvider::new())); + info!("Registered systemd provider (type_id: uuctl)"); + } + let mut builtin_dynamic: Vec> = Vec::new(); if calc_enabled { builtin_dynamic.push(Box::new(calculator::CalculatorProvider)); diff --git a/crates/owlry/src/providers/systemd.rs b/crates/owlry/src/providers/systemd.rs new file mode 100644 index 0000000..fbfb952 --- /dev/null +++ b/crates/owlry/src/providers/systemd.rs @@ -0,0 +1,364 @@ +//! systemd user services provider. +//! +//! Lists user-level systemd services and exposes start/stop/restart/enable/ +//! disable/status/journal as submenu actions. type_id stays `uuctl` for CLI +//! and config back-compat. + +use super::{ItemSource, LaunchItem, Provider, ProviderType}; +use std::process::Command; + +const TYPE_ID: &str = "uuctl"; + +pub struct SystemdProvider { + items: Vec, +} + +impl Default for SystemdProvider { + fn default() -> Self { + Self::new() + } +} + +impl SystemdProvider { + pub fn new() -> Self { + Self { items: Vec::new() } + } + + fn systemctl_available() -> bool { + Command::new("systemctl") + .args(["--user", "--version"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn parse_systemctl_output(output: &str) -> Vec { + let mut items = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + let mut parts = line.split_whitespace(); + + let unit_name = match parts.next() { + Some(u) => u, + None => continue, + }; + + if !unit_name.ends_with(".service") { + continue; + } + + let _load_state = parts.next().unwrap_or(""); + let active_state = parts.next().unwrap_or(""); + let sub_state = parts.next().unwrap_or(""); + let description: String = parts.collect::>().join(" "); + + let display_name = clean_display_name(unit_name); + + let is_active = active_state == "active"; + let status_icon = if is_active { "●" } else { "○" }; + + let status_desc = if description.is_empty() { + format!("{} {} ({})", status_icon, sub_state, active_state) + } else { + format!("{} {} ({})", status_icon, description, sub_state) + }; + + // SUBMENU:: is the protocol the UI uses to route + // a selection back to this provider's submenu_actions(). + let submenu_data = format!("SUBMENU:{}:{}:{}", TYPE_ID, unit_name, is_active); + + let icon = if is_active { + "emblem-ok-symbolic" + } else { + "emblem-pause-symbolic" + }; + + items.push(LaunchItem { + id: format!("systemd:service:{}", unit_name), + name: display_name, + description: Some(status_desc), + icon: Some(icon.to_string()), + provider: ProviderType::Plugin(TYPE_ID.into()), + command: submenu_data, + terminal: false, + tags: vec!["systemd".to_string(), "service".to_string()], + source: ItemSource::Core, + }); + } + + items + } +} + +impl Provider for SystemdProvider { + fn name(&self) -> &str { + "User Units" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(TYPE_ID.into()) + } + + fn refresh(&mut self) { + self.items.clear(); + + if !Self::systemctl_available() { + return; + } + + let output = match Command::new("systemctl") + .args([ + "--user", + "list-units", + "--type=service", + "--all", + "--no-legend", + "--no-pager", + ]) + .output() + { + Ok(o) if o.status.success() => o, + _ => return, + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + self.items = Self::parse_systemctl_output(&stdout); + self.items + .sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } + + fn prefix(&self) -> Option<&str> { + Some(":uuctl") + } + + fn icon(&self) -> &str { + "system-run" + } + + fn tab_label(&self) -> Option<&str> { + Some("Units") + } + + fn search_noun(&self) -> Option<&str> { + Some("systemd units") + } + + /// Submenu data is `:` (encoded by `refresh()`). + fn submenu_actions(&self, data: &str) -> Vec { + let parts: Vec<&str> = data.splitn(2, ':').collect(); + let (unit_name, is_active) = match parts.as_slice() { + [unit, active] if !unit.is_empty() => (*unit, *active == "true"), + [unit] if !unit.is_empty() => (*unit, false), + _ => return Vec::new(), + }; + let display = clean_display_name(unit_name); + actions_for_service(unit_name, &display, is_active) + } +} + +fn clean_display_name(unit_name: &str) -> String { + unit_name + .trim_end_matches(".service") + .replace("app-", "") + .replace("@autostart", "") + .replace("\\x2d", "-") +} + +fn make_action(id: &str, name: &str, command: String, desc: String, icon: &str) -> LaunchItem { + LaunchItem { + id: id.to_string(), + name: name.to_string(), + description: Some(desc), + icon: Some(icon.to_string()), + provider: ProviderType::Plugin(TYPE_ID.into()), + command, + terminal: false, + tags: vec!["systemd".to_string(), "service".to_string()], + source: ItemSource::Core, + } +} + +fn actions_for_service(unit: &str, display: &str, is_active: bool) -> Vec { + let mut actions = Vec::new(); + + if is_active { + actions.push(make_action( + &format!("systemd:restart:{}", unit), + "↻ Restart", + format!("systemctl --user restart {}", unit), + format!("Restart {}", display), + "view-refresh", + )); + actions.push(make_action( + &format!("systemd:stop:{}", unit), + "■ Stop", + format!("systemctl --user stop {}", unit), + format!("Stop {}", display), + "process-stop", + )); + actions.push(make_action( + &format!("systemd:reload:{}", unit), + "⟳ Reload", + format!("systemctl --user reload {}", unit), + format!("Reload {} configuration", display), + "view-refresh", + )); + actions.push(make_action( + &format!("systemd:kill:{}", unit), + "✗ Kill", + format!("systemctl --user kill {}", unit), + format!("Force kill {}", display), + "edit-delete", + )); + } else { + actions.push(make_action( + &format!("systemd:start:{}", unit), + "▶ Start", + format!("systemctl --user start {}", unit), + format!("Start {}", display), + "media-playback-start", + )); + } + + // Always-available actions. Status and Journal need a terminal. + let mut status = make_action( + &format!("systemd:status:{}", unit), + "ℹ Status", + format!("systemctl --user status {}", unit), + format!("Show {} status", display), + "dialog-information", + ); + status.terminal = true; + actions.push(status); + + let mut journal = make_action( + &format!("systemd:journal:{}", unit), + "📋 Journal", + format!("journalctl --user -u {} -f", unit), + format!("Show {} logs", display), + "utilities-system-monitor", + ); + journal.terminal = true; + actions.push(journal); + + actions.push(make_action( + &format!("systemd:enable:{}", unit), + "⊕ Enable", + format!("systemctl --user enable {}", unit), + format!("Enable {} on startup", display), + "emblem-default", + )); + actions.push(make_action( + &format!("systemd:disable:{}", unit), + "⊖ Disable", + format!("systemctl --user disable {}", unit), + format!("Disable {} on startup", display), + "emblem-unreadable", + )); + + actions +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_systemctl_output_extracts_services_with_submenu_command() { + let output = " +foo.service loaded active running Foo Service +bar.service loaded inactive dead Bar Service +baz@autostart.service loaded active running Baz App +"; + let items = SystemdProvider::parse_systemctl_output(output); + assert_eq!(items.len(), 3); + + // Active service: SUBMENU command with is_active=true. + assert_eq!(items[0].name, "foo"); + assert!(items[0].command.contains("SUBMENU:uuctl:foo.service:true")); + + // Inactive service: SUBMENU command with is_active=false. + assert_eq!(items[1].name, "bar"); + assert!(items[1].command.contains("SUBMENU:uuctl:bar.service:false")); + + // Display name cleaning strips @autostart suffix. + assert_eq!(items[2].name, "baz"); + } + + #[test] + fn parse_systemctl_output_skips_non_service_lines() { + let output = " +foo.service loaded active running Foo +bar.socket loaded active listening Bar +quux.timer loaded active waiting Quux +baz.service loaded inactive dead Baz +"; + let items = SystemdProvider::parse_systemctl_output(output); + assert_eq!(items.len(), 2); + assert_eq!(items[0].name, "foo"); + assert_eq!(items[1].name, "baz"); + } + + #[test] + fn provider_type_is_uuctl_plugin() { + // CLI back-compat: `-m uuctl` and `:uuctl` both resolve to this type_id. + let p = SystemdProvider::new(); + assert_eq!(p.provider_type(), ProviderType::Plugin("uuctl".into())); + } + + #[test] + fn submenu_actions_for_active_service_includes_restart_stop_not_start() { + let p = SystemdProvider::new(); + let actions = p.submenu_actions("nginx.service:true"); + let ids: Vec<&str> = actions.iter().map(|a| a.id.as_str()).collect(); + assert!(ids.contains(&"systemd:restart:nginx.service")); + assert!(ids.contains(&"systemd:stop:nginx.service")); + assert!(ids.contains(&"systemd:status:nginx.service")); + assert!(!ids.contains(&"systemd:start:nginx.service")); + } + + #[test] + fn submenu_actions_for_inactive_service_includes_start_not_stop() { + let p = SystemdProvider::new(); + let actions = p.submenu_actions("nginx.service:false"); + let ids: Vec<&str> = actions.iter().map(|a| a.id.as_str()).collect(); + assert!(ids.contains(&"systemd:start:nginx.service")); + assert!(ids.contains(&"systemd:status:nginx.service")); + assert!(!ids.contains(&"systemd:stop:nginx.service")); + } + + #[test] + fn submenu_actions_returns_empty_for_garbage_data() { + let p = SystemdProvider::new(); + assert!(p.submenu_actions("").is_empty()); + } + + #[test] + fn submenu_actions_terminal_flag_set_on_status_and_journal() { + let p = SystemdProvider::new(); + let actions = p.submenu_actions("test.service:true"); + for action in &actions { + if action.id.contains(":status:") || action.id.contains(":journal:") { + assert!(action.terminal, "{} must have terminal=true", action.id); + } + } + } + + #[test] + fn clean_display_name_strips_service_suffix_and_known_prefixes() { + assert_eq!(clean_display_name("foo.service"), "foo"); + assert_eq!(clean_display_name("app-firefox.service"), "firefox"); + assert_eq!(clean_display_name("bar@autostart.service"), "bar"); + // The hex-escaped dash that systemd uses for some unit names. + assert_eq!(clean_display_name("foo\\x2dbar.service"), "foo-bar"); + } +} From cb2ea5973ba06a451d6344c532d1150333922283 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 02:17:42 +0200 Subject: [PATCH 07/23] feat(providers): convert remaining 6 plugins from C-ABI to native impls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the v2 plugin conversion. Six providers ported from the owlry-plugins sibling repo into the single owlry crate as feature- gated modules. Each follows the same pattern established by systemd: drop extern "C"/PluginItem/ProviderHandle/owlry_plugin! scaffolding, implement Provider or DynamicProvider directly on a regular struct. Static providers (Provider trait, populate via refresh): - providers/bookmarks.rs — Firefox + Chromium bookmarks via rusqlite, favicon cache preserved. dep: rusqlite (bundled), feature: bookmarks - providers/clipboard.rs — cliphist history. feature: clipboard - providers/emoji.rs — bundled emoji list with keyword tags. feature: emoji - providers/ssh.rs — ~/.ssh/config host extraction. feature: ssh Dynamic providers (DynamicProvider trait, generate per query): - providers/filesearch.rs — fd / mlocate shellout with extract_search_term for ':file' and '/' triggers. feature: filesearch - providers/websearch.rs — URL builder with DuckDuckGo/Google/custom engines. TODO: plumb engine through constructor once Lua config lands (Phase 3). feature: websearch Wiring: - Cargo.toml: 7 per-provider features + 'full' meta-feature. rusqlite added as optional dep (only pulled in with feature 'bookmarks'). - config/mod.rs: ProvidersConfig gains 6 new bool fields (defaults true) - providers/mod.rs: gated module declarations + new_with_config takes a config snapshot and registers each provider behind its feature flag Verification across feature axes: - --no-default-features: 178 tests pass (feature-gated modules excluded) - default (systemd only): 186 tests pass - --features full: 233 tests pass (+55 from the 6 new conversions) Tasks #6 and #7 complete. --- Cargo.lock | 81 +++- crates/owlry/Cargo.toml | 19 +- crates/owlry/src/config/mod.rs | 24 ++ crates/owlry/src/providers/bookmarks.rs | 469 ++++++++++++++++++++ crates/owlry/src/providers/clipboard.rs | 249 +++++++++++ crates/owlry/src/providers/emoji.rs | 518 +++++++++++++++++++++++ crates/owlry/src/providers/filesearch.rs | 267 ++++++++++++ crates/owlry/src/providers/mod.rs | 62 ++- crates/owlry/src/providers/ssh.rs | 290 +++++++++++++ crates/owlry/src/providers/websearch.rs | 244 +++++++++++ docs/RESTRUCTURE-V2.md | 4 +- 11 files changed, 2211 insertions(+), 16 deletions(-) create mode 100644 crates/owlry/src/providers/bookmarks.rs create mode 100644 crates/owlry/src/providers/clipboard.rs create mode 100644 crates/owlry/src/providers/emoji.rs create mode 100644 crates/owlry/src/providers/filesearch.rs create mode 100644 crates/owlry/src/providers/ssh.rs create mode 100644 crates/owlry/src/providers/websearch.rs diff --git a/Cargo.lock b/Cargo.lock index e975510..d855876 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -580,6 +580,18 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -608,6 +620,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1192,7 +1210,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1200,6 +1218,18 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] [[package]] name = "heck" @@ -1557,6 +1587,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1841,6 +1882,7 @@ dependencies = [ "log", "notify-rust", "reqwest", + "rusqlite", "serde", "serde_json", "signal-hook", @@ -2087,6 +2129,31 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rusqlite" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -2282,6 +2349,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/crates/owlry/Cargo.toml b/crates/owlry/Cargo.toml index fe0a3de..a88033d 100644 --- a/crates/owlry/Cargo.toml +++ b/crates/owlry/Cargo.toml @@ -60,6 +60,9 @@ signal-hook = "0.3" expr-solver-lib = "1" reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "blocking"] } +# Optional providers (gated by cargo features below) +rusqlite = { version = "0.39", features = ["bundled"], optional = true } + # Async oneshot channel (background thread -> main loop) futures-channel = "0.3" @@ -78,6 +81,20 @@ dev-logging = [] # Optional providers (compiled in when enabled). # The AUR PKGBUILD builds with --features "full" to ship everything. +bookmarks = ["dep:rusqlite"] +clipboard = [] +emoji = [] +filesearch = [] +ssh = [] systemd = [] +websearch = [] -full = ["systemd"] +full = [ + "bookmarks", + "clipboard", + "emoji", + "filesearch", + "ssh", + "systemd", + "websearch", +] diff --git a/crates/owlry/src/config/mod.rs b/crates/owlry/src/config/mod.rs index f70622c..48d158a 100644 --- a/crates/owlry/src/config/mod.rs +++ b/crates/owlry/src/config/mod.rs @@ -174,6 +174,24 @@ pub struct ProvidersConfig { /// Enable systemd user units provider (alias: uuctl) #[serde(default = "default_true", alias = "uuctl")] pub systemd: bool, + /// Enable bookmarks provider (Firefox + Chromium) + #[serde(default = "default_true")] + pub bookmarks: bool, + /// Enable clipboard history provider (requires cliphist) + #[serde(default = "default_true")] + pub clipboard: bool, + /// Enable emoji picker provider + #[serde(default = "default_true")] + pub emoji: bool, + /// Enable filesystem search provider (uses fd or mlocate) + #[serde(default = "default_true")] + pub filesearch: bool, + /// Enable SSH host provider (parses ~/.ssh/config) + #[serde(default = "default_true")] + pub ssh: bool, + /// Enable web search provider (DuckDuckGo / configurable) + #[serde(default = "default_true")] + pub websearch: bool, /// Enable frecency-based result ranking #[serde(default = "default_true")] pub frecency: bool, @@ -196,6 +214,12 @@ impl Default for ProvidersConfig { converter: true, system: true, systemd: true, + bookmarks: true, + clipboard: true, + emoji: true, + filesearch: true, + ssh: true, + websearch: true, frecency: true, frecency_weight: 0.3, search_engine: "duckduckgo".to_string(), diff --git a/crates/owlry/src/providers/bookmarks.rs b/crates/owlry/src/providers/bookmarks.rs new file mode 100644 index 0000000..12e8908 --- /dev/null +++ b/crates/owlry/src/providers/bookmarks.rs @@ -0,0 +1,469 @@ +//! Browser bookmarks provider. +//! +//! Reads bookmarks from Firefox (`places.sqlite` via `rusqlite`) and +//! Chromium-family browsers (Chrome, Chromium, Brave, Edge — JSON +//! `Bookmarks` files). Items launch via `xdg-open `. +//! +//! This is a static provider: `refresh()` populates `self.items`, and the +//! core fuzzy-matches against the cached list. + +use super::{ItemSource, LaunchItem, Provider, ProviderType}; +use rusqlite::{Connection, OpenFlags}; +use serde::Deserialize; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +const TYPE_ID: &str = "bookmarks"; +const ICON: &str = "user-bookmarks-symbolic"; + +pub struct BookmarksProvider { + items: Vec, +} + +impl Default for BookmarksProvider { + fn default() -> Self { + Self::new() + } +} + +impl BookmarksProvider { + pub fn new() -> Self { + Self { items: Vec::new() } + } + + /// Cache directory for downloaded Firefox favicons. + fn favicon_cache_dir() -> Option { + dirs::cache_dir().map(|d| d.join("owlry/favicons")) + } + + fn ensure_favicon_cache_dir() -> Option { + Self::favicon_cache_dir().and_then(|dir| { + fs::create_dir_all(&dir).ok()?; + Some(dir) + }) + } + + /// Hash a URL into a stable cache filename. Collisions are acceptable — + /// the worst case is a favicon shown for the wrong bookmark. + fn url_to_cache_filename(url: &str) -> String { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + url.hash(&mut hasher); + format!("{:016x}.png", hasher.finish()) + } + + fn chromium_bookmark_paths() -> Vec { + let mut paths = Vec::new(); + if let Some(config_dir) = dirs::config_dir() { + // Chrome + paths.push(config_dir.join("google-chrome/Default/Bookmarks")); + paths.push(config_dir.join("google-chrome-stable/Default/Bookmarks")); + // Chromium + paths.push(config_dir.join("chromium/Default/Bookmarks")); + // Brave + paths.push(config_dir.join("BraveSoftware/Brave-Browser/Default/Bookmarks")); + // Edge + paths.push(config_dir.join("microsoft-edge/Default/Bookmarks")); + } + paths + } + + fn firefox_places_paths() -> Vec { + let mut paths = Vec::new(); + if let Some(home) = dirs::home_dir() { + let firefox_dir = home.join(".mozilla/firefox"); + if firefox_dir.exists() + && let Ok(entries) = fs::read_dir(&firefox_dir) + { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let places = path.join("places.sqlite"); + if places.exists() { + paths.push(places); + } + } + } + } + } + paths + } + + /// Sibling `favicons.sqlite` next to a given `places.sqlite`, if present. + fn firefox_favicons_path(places_path: &Path) -> Option { + let favicons = places_path.parent()?.join("favicons.sqlite"); + if favicons.exists() { + Some(favicons) + } else { + None + } + } + + fn read_chrome_bookmarks(path: &Path, items: &mut Vec) { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return, + }; + + let bookmarks: ChromeBookmarks = match serde_json::from_str(&content) { + Ok(b) => b, + Err(_) => return, + }; + + if let Some(roots) = bookmarks.roots { + if let Some(bar) = roots.bookmark_bar { + Self::process_chrome_folder(&bar, items); + } + if let Some(other) = roots.other { + Self::process_chrome_folder(&other, items); + } + if let Some(synced) = roots.synced { + Self::process_chrome_folder(&synced, items); + } + } + } + + fn process_chrome_folder(folder: &ChromeBookmarkNode, items: &mut Vec) { + if let Some(ref children) = folder.children { + for child in children { + match child.node_type.as_deref() { + Some("url") => { + if let Some(ref url) = child.url { + let name = child.name.clone().unwrap_or_else(|| url.clone()); + items.push(LaunchItem { + id: format!("bookmark:{}", url), + name, + description: Some(url.clone()), + icon: Some(ICON.to_string()), + provider: ProviderType::Plugin(TYPE_ID.into()), + command: format!("xdg-open '{}'", url.replace('\'', "'\\''")), + terminal: false, + tags: vec!["bookmark".to_string(), "chrome".to_string()], + source: ItemSource::Core, + }); + } + } + Some("folder") => { + Self::process_chrome_folder(child, items); + } + _ => {} + } + } + } + } + + /// Read Firefox bookmarks via `rusqlite`. Copies the DB (+ WAL) into a + /// temp file first so we can open in read-only mode without contesting + /// the lock Firefox holds while running. + fn read_firefox_bookmarks(places_path: &Path, items: &mut Vec) { + let temp_dir = std::env::temp_dir(); + let pid = std::process::id(); + let temp_db = temp_dir.join(format!("owlry_places_{}.sqlite", pid)); + + if fs::copy(places_path, &temp_db).is_err() { + return; + } + + 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); + } + + 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(); + let bookmarks = Self::fetch_firefox_bookmarks(&temp_db, &temp_favicons, cache_dir.as_ref()); + + 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")); + + for (title, url, favicon_path) in bookmarks { + let icon = favicon_path.unwrap_or_else(|| ICON.to_string()); + items.push(LaunchItem { + id: format!("bookmark:firefox:{}", url), + name: title, + description: Some(url.clone()), + icon: Some(icon), + provider: ProviderType::Plugin(TYPE_ID.into()), + command: format!("xdg-open '{}'", url.replace('\'', "'\\''")), + terminal: false, + tags: vec!["bookmark".to_string(), "firefox".to_string()], + source: ItemSource::Core, + }); + } + } + + /// Run the bookmark SELECT against `places.sqlite` and, if available, + /// fetch + cache favicons from `favicons.sqlite`. + fn fetch_firefox_bookmarks( + places_path: &Path, + favicons_path: &Path, + cache_dir: Option<&PathBuf>, + ) -> Vec<(String, String, Option)> { + let conn = match Connection::open_with_flags( + places_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + // type=1 means URL bookmarks (not folders, separators, etc.). We + // skip Firefox-internal `place:` and `about:` URLs. + let query = r#" + SELECT b.title, p.url + FROM moz_bookmarks b + JOIN moz_places p ON b.fk = p.id + WHERE b.type = 1 + AND p.url NOT LIKE 'place:%' + AND p.url NOT LIKE 'about:%' + AND b.title IS NOT NULL + AND b.title != '' + ORDER BY b.dateAdded DESC + LIMIT 500 + "#; + + let mut stmt = match conn.prepare(query) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + + let bookmarks: Vec<(String, String)> = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .ok() + .map(|rows| rows.filter_map(|r| r.ok()).collect()) + .unwrap_or_default(); + + let cache_dir = match cache_dir { + Some(c) => c, + None => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(), + }; + + let fav_conn = match Connection::open_with_flags( + favicons_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) { + Ok(c) => c, + Err(_) => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(), + }; + + let mut results = Vec::new(); + for (title, url) in bookmarks { + let favicon_path = Self::get_favicon_for_url(&fav_conn, &url, cache_dir); + results.push((title, url, favicon_path)); + } + + results + } + + /// Look up a favicon for `page_url` in Firefox's `favicons.sqlite`, writing + /// the blob to the cache directory on first hit. Returns the cached path + /// (or None if no favicon / write failed). + fn get_favicon_for_url(conn: &Connection, page_url: &str, cache_dir: &Path) -> Option { + let cache_filename = Self::url_to_cache_filename(page_url); + let cache_path = cache_dir.join(&cache_filename); + if cache_path.exists() { + return Some(cache_path.to_string_lossy().to_string()); + } + + // Prefer the 32px-ish icon if multiple sizes exist. + let query = r#" + SELECT i.data + FROM moz_pages_w_icons p + JOIN moz_icons_to_pages ip ON p.id = ip.page_id + JOIN moz_icons i ON ip.icon_id = i.id + WHERE p.page_url = ? + AND i.data IS NOT NULL + ORDER BY ABS(i.width - 32) ASC + LIMIT 1 + "#; + + let data: Option> = conn.query_row(query, [page_url], |row| row.get(0)).ok(); + + let data = data?; + if data.is_empty() { + return None; + } + + let mut file = fs::File::create(&cache_path).ok()?; + file.write_all(&data).ok()?; + + Some(cache_path.to_string_lossy().to_string()) + } +} + +impl Provider for BookmarksProvider { + fn name(&self) -> &str { + "Bookmarks" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(TYPE_ID.into()) + } + + fn refresh(&mut self) { + self.items.clear(); + + for path in Self::chromium_bookmark_paths() { + if path.exists() { + Self::read_chrome_bookmarks(&path, &mut self.items); + } + } + + for path in Self::firefox_places_paths() { + Self::read_firefox_bookmarks(&path, &mut self.items); + } + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } + + fn prefix(&self) -> Option<&str> { + Some(":bm") + } + + fn icon(&self) -> &str { + ICON + } + + fn tab_label(&self) -> Option<&str> { + Some("Bookmarks") + } + + fn search_noun(&self) -> Option<&str> { + Some("bookmarks") + } +} + +// Chrome's `Bookmarks` JSON layout. +#[derive(Debug, Deserialize)] +struct ChromeBookmarks { + roots: Option, +} + +#[derive(Debug, Deserialize)] +struct ChromeBookmarkRoots { + bookmark_bar: Option, + other: Option, + synced: Option, +} + +#[derive(Debug, Deserialize)] +struct ChromeBookmarkNode { + name: Option, + url: Option, + #[serde(rename = "type")] + node_type: Option, + children: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bookmarks_state_new() { + let p = BookmarksProvider::new(); + assert!(p.items().is_empty()); + } + + #[test] + fn test_chromium_paths() { + let paths = BookmarksProvider::chromium_bookmark_paths(); + // Should have at least some paths configured (5 known browsers). + assert!(!paths.is_empty()); + } + + #[test] + fn test_firefox_paths() { + // Path detection should not panic, even if Firefox isn't installed. + let paths = BookmarksProvider::firefox_places_paths(); + let _ = paths.len(); + } + + #[test] + fn test_parse_chrome_bookmarks() { + let json = r#"{ + "roots": { + "bookmark_bar": { + "type": "folder", + "children": [ + { + "type": "url", + "name": "Example", + "url": "https://example.com" + } + ] + } + } + }"#; + + let bookmarks: ChromeBookmarks = serde_json::from_str(json).unwrap(); + assert!(bookmarks.roots.is_some()); + + let roots = bookmarks.roots.unwrap(); + assert!(roots.bookmark_bar.is_some()); + + let bar = roots.bookmark_bar.unwrap(); + assert!(bar.children.is_some()); + assert_eq!(bar.children.unwrap().len(), 1); + } + + #[test] + fn test_process_folder() { + let mut items = Vec::new(); + + let folder = ChromeBookmarkNode { + name: Some("Test Folder".to_string()), + url: None, + node_type: Some("folder".to_string()), + children: Some(vec![ChromeBookmarkNode { + name: Some("Test Bookmark".to_string()), + url: Some("https://test.com".to_string()), + node_type: Some("url".to_string()), + children: None, + }]), + }; + + BookmarksProvider::process_chrome_folder(&folder, &mut items); + assert_eq!(items.len(), 1); + assert_eq!(items[0].name, "Test Bookmark"); + assert_eq!(items[0].provider, ProviderType::Plugin("bookmarks".into())); + assert_eq!(items[0].source, ItemSource::Core); + assert!(items[0].command.starts_with("xdg-open '")); + } + + #[test] + fn test_url_escaping() { + let url = "https://example.com/path?query='test'"; + let command = format!("xdg-open '{}'", url.replace('\'', "'\\''")); + assert!(command.contains("'\\''")); + } + + #[test] + fn provider_type_is_bookmarks_plugin() { + let p = BookmarksProvider::new(); + assert_eq!(p.provider_type(), ProviderType::Plugin("bookmarks".into())); + } + + #[test] + fn provider_prefix_is_bm() { + let p = BookmarksProvider::new(); + assert_eq!(p.prefix(), Some(":bm")); + } +} diff --git a/crates/owlry/src/providers/clipboard.rs b/crates/owlry/src/providers/clipboard.rs new file mode 100644 index 0000000..3e54d3f --- /dev/null +++ b/crates/owlry/src/providers/clipboard.rs @@ -0,0 +1,249 @@ +//! Clipboard history provider. +//! +//! Static provider that surfaces `cliphist` entries as launch items. Selecting +//! an item runs `cliphist decode | wl-copy` to put the entry back on the +//! clipboard. Requires `cliphist` and `wl-clipboard` to be installed. + +use super::{ItemSource, LaunchItem, Provider, ProviderType}; +use std::process::Command; + +const TYPE_ID: &str = "clipboard"; +const PROVIDER_ICON: &str = "edit-paste"; +const DEFAULT_MAX_ENTRIES: usize = 50; + +pub struct ClipboardProvider { + items: Vec, + max_entries: usize, +} + +impl Default for ClipboardProvider { + fn default() -> Self { + Self::new() + } +} + +impl ClipboardProvider { + pub fn new() -> Self { + Self { + items: Vec::new(), + max_entries: DEFAULT_MAX_ENTRIES, + } + } + + fn has_cliphist() -> bool { + Command::new("which") + .arg("cliphist") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn parse_cliphist_output(output: &str, max_entries: usize) -> Vec { + let mut items = Vec::new(); + + for (idx, line) in output.lines().take(max_entries).enumerate() { + // cliphist format: "id\tpreview" + let parts: Vec<&str> = line.splitn(2, '\t').collect(); + + if parts.is_empty() { + continue; + } + + let clip_id = parts[0]; + let preview = if parts.len() > 1 { + // Truncate long previews (char-safe for UTF-8) + let p = parts[1]; + if p.chars().count() > 80 { + let truncated: String = p.chars().take(77).collect(); + format!("{}...", truncated) + } else { + p.to_string() + } + } else { + "[binary data]".to_string() + }; + + // Clean up preview - replace newlines/tabs with spaces, strip CR. + let preview_clean = preview + .replace('\n', " ") + .replace('\r', "") + .replace('\t', " "); + + // Command to paste this entry: echo "id" | cliphist decode | wl-copy + let command = format!( + "echo '{}' | cliphist decode | wl-copy", + clip_id.replace('\'', "'\\''") + ); + + items.push(LaunchItem { + id: format!("clipboard:{}", idx), + name: preview_clean, + description: Some("Copy to clipboard".to_string()), + icon: Some(PROVIDER_ICON.to_string()), + provider: ProviderType::Plugin(TYPE_ID.into()), + command, + terminal: false, + tags: vec!["clipboard".to_string()], + source: ItemSource::Core, + }); + } + + items + } +} + +impl Provider for ClipboardProvider { + fn name(&self) -> &str { + "Clipboard" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(TYPE_ID.into()) + } + + fn refresh(&mut self) { + self.items.clear(); + + if !Self::has_cliphist() { + return; + } + + let output = match Command::new("cliphist").arg("list").output() { + Ok(o) if o.status.success() => o, + _ => return, + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + self.items = Self::parse_cliphist_output(&stdout, self.max_entries); + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } + + fn prefix(&self) -> Option<&str> { + Some(":clip") + } + + fn icon(&self) -> &str { + PROVIDER_ICON + } + + fn tab_label(&self) -> Option<&str> { + Some("Clipboard") + } + + fn search_noun(&self) -> Option<&str> { + Some("clipboard entries") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clipboard_provider_new() { + let p = ClipboardProvider::new(); + assert!(p.items.is_empty()); + assert_eq!(p.max_entries, DEFAULT_MAX_ENTRIES); + } + + #[test] + fn test_preview_truncation() { + // Long ASCII strings get truncated char-safely to 80 chars with "..." suffix. + let long_text = "a".repeat(100); + let truncated = if long_text.chars().count() > 80 { + let t: String = long_text.chars().take(77).collect(); + format!("{}...", t) + } else { + long_text.clone() + }; + assert_eq!(truncated.chars().count(), 80); + assert!(truncated.ends_with("...")); + } + + #[test] + fn test_preview_truncation_utf8() { + // Multi-byte UTF-8 chars must not split mid-codepoint. + let utf8_text = "├── ".repeat(30); + let truncated = if utf8_text.chars().count() > 80 { + let t: String = utf8_text.chars().take(77).collect(); + format!("{}...", t) + } else { + utf8_text.clone() + }; + assert_eq!(truncated.chars().count(), 80); + assert!(truncated.ends_with("...")); + } + + #[test] + fn test_preview_cleaning() { + let dirty = "line1\nline2\tcolumn\rend"; + let clean = dirty + .replace('\n', " ") + .replace('\r', "") + .replace('\t', " "); + assert_eq!(clean, "line1 line2 columnend"); + } + + #[test] + fn test_command_escaping() { + let clip_id = "test'id"; + let command = format!( + "echo '{}' | cliphist decode | wl-copy", + clip_id.replace('\'', "'\\''") + ); + assert!(command.contains("test'\\''id")); + } + + #[test] + fn test_has_cliphist_runs() { + // Just ensure it doesn't panic — cliphist may or may not be installed. + let _ = ClipboardProvider::has_cliphist(); + } + + #[test] + fn parse_cliphist_output_builds_items_with_plugin_provider_type() { + let output = "1\thello world\n2\tanother entry\n"; + let items = ClipboardProvider::parse_cliphist_output(output, DEFAULT_MAX_ENTRIES); + assert_eq!(items.len(), 2); + assert_eq!(items[0].name, "hello world"); + assert_eq!(items[0].provider, ProviderType::Plugin("clipboard".into())); + assert_eq!(items[0].source, ItemSource::Core); + assert!(items[0].command.contains("cliphist decode")); + assert!(items[0].command.contains("wl-copy")); + assert!(!items[0].terminal); + } + + #[test] + fn parse_cliphist_output_respects_max_entries() { + let output = "1\ta\n2\tb\n3\tc\n4\td\n"; + let items = ClipboardProvider::parse_cliphist_output(output, 2); + assert_eq!(items.len(), 2); + } + + #[test] + fn parse_cliphist_output_handles_missing_preview_as_binary() { + let output = "42\n"; + let items = ClipboardProvider::parse_cliphist_output(output, DEFAULT_MAX_ENTRIES); + assert_eq!(items.len(), 1); + assert_eq!(items[0].name, "[binary data]"); + } + + #[test] + fn provider_type_is_clipboard_plugin() { + // CLI back-compat: `-m clip` / `:clip` resolve to this type_id. + let p = ClipboardProvider::new(); + assert_eq!(p.provider_type(), ProviderType::Plugin("clipboard".into())); + } + + #[test] + fn provider_metadata_exposes_clip_prefix_and_labels() { + let p = ClipboardProvider::new(); + assert_eq!(p.prefix(), Some(":clip")); + assert_eq!(p.icon(), "edit-paste"); + assert_eq!(p.tab_label(), Some("Clipboard")); + assert_eq!(p.search_noun(), Some("clipboard entries")); + } +} diff --git a/crates/owlry/src/providers/emoji.rs b/crates/owlry/src/providers/emoji.rs new file mode 100644 index 0000000..ff22749 --- /dev/null +++ b/crates/owlry/src/providers/emoji.rs @@ -0,0 +1,518 @@ +//! Emoji provider. +//! +//! Static provider exposing a curated set of emojis. Selecting an item copies +//! the emoji glyph to the Wayland clipboard via `wl-copy`. Search matches the +//! human-readable name and the keyword bag. + +use super::{ItemSource, LaunchItem, Provider, ProviderType}; + +const TYPE_ID: &str = "emoji"; + +/// (emoji, name, space-separated keywords) +const EMOJIS: &[(&str, &str, &str)] = &[ + // Smileys & Emotion + ("😀", "grinning face", "smile happy"), + ("😃", "grinning face with big eyes", "smile happy"), + ("😄", "grinning face with smiling eyes", "smile happy laugh"), + ("😁", "beaming face with smiling eyes", "smile happy grin"), + ("😅", "grinning face with sweat", "smile nervous"), + ("🤣", "rolling on the floor laughing", "lol rofl funny"), + ("😂", "face with tears of joy", "laugh cry funny lol"), + ("🙂", "slightly smiling face", "smile"), + ("😊", "smiling face with smiling eyes", "blush happy"), + ("😇", "smiling face with halo", "angel innocent"), + ("🥰", "smiling face with hearts", "love adore"), + ("😍", "smiling face with heart-eyes", "love crush"), + ("🤩", "star-struck", "excited wow amazing"), + ("😘", "face blowing a kiss", "kiss love"), + ("😜", "winking face with tongue", "playful silly"), + ("🤪", "zany face", "crazy silly wild"), + ("😎", "smiling face with sunglasses", "cool"), + ("🤓", "nerd face", "geek glasses"), + ("🧐", "face with monocle", "thinking inspect"), + ("😏", "smirking face", "smug"), + ("😒", "unamused face", "meh annoyed"), + ("🙄", "face with rolling eyes", "whatever annoyed"), + ("😬", "grimacing face", "awkward nervous"), + ("😮‍💨", "face exhaling", "sigh relief"), + ("🤥", "lying face", "pinocchio lie"), + ("😌", "relieved face", "relaxed peaceful"), + ("😔", "pensive face", "sad thoughtful"), + ("😪", "sleepy face", "tired"), + ("🤤", "drooling face", "hungry yummy"), + ("😴", "sleeping face", "zzz tired"), + ("😷", "face with medical mask", "sick covid"), + ("🤒", "face with thermometer", "sick fever"), + ("🤕", "face with head-bandage", "hurt injured"), + ("🤢", "nauseated face", "sick gross"), + ("🤮", "face vomiting", "sick puke"), + ("🤧", "sneezing face", "achoo sick"), + ("🥵", "hot face", "sweating heat"), + ("🥶", "cold face", "freezing"), + ("😵", "face with crossed-out eyes", "dizzy dead"), + ("🤯", "exploding head", "mind blown wow"), + ("🤠", "cowboy hat face", "yeehaw western"), + ("🥳", "partying face", "celebration party"), + ("🥸", "disguised face", "incognito"), + ("🤡", "clown face", "circus"), + ("👻", "ghost", "halloween spooky"), + ("💀", "skull", "dead death"), + ("☠️", "skull and crossbones", "danger death"), + ("👽", "alien", "ufo extraterrestrial"), + ("🤖", "robot", "bot android"), + ("💩", "pile of poo", "poop"), + ("😈", "smiling face with horns", "devil evil"), + ("👿", "angry face with horns", "devil evil"), + // Gestures & People + ("👋", "waving hand", "hello hi bye wave"), + ("🤚", "raised back of hand", "stop"), + ("🖐️", "hand with fingers splayed", "five high"), + ("✋", "raised hand", "stop high five"), + ("🖖", "vulcan salute", "spock trek"), + ("👌", "ok hand", "okay perfect"), + ("🤌", "pinched fingers", "italian"), + ("🤏", "pinching hand", "small tiny"), + ("✌️", "victory hand", "peace two"), + ("🤞", "crossed fingers", "luck hope"), + ("🤟", "love-you gesture", "ily rock"), + ("🤘", "sign of the horns", "rock metal"), + ("🤙", "call me hand", "shaka hang loose"), + ("👈", "backhand index pointing left", "left point"), + ("👉", "backhand index pointing right", "right point"), + ("👆", "backhand index pointing up", "up point"), + ("👇", "backhand index pointing down", "down point"), + ("☝️", "index pointing up", "one point"), + ("👍", "thumbs up", "like yes good approve"), + ("👎", "thumbs down", "dislike no bad"), + ("✊", "raised fist", "power solidarity"), + ("👊", "oncoming fist", "punch bump"), + ("🤛", "left-facing fist", "fist bump"), + ("🤜", "right-facing fist", "fist bump"), + ("👏", "clapping hands", "applause bravo"), + ("🙌", "raising hands", "hooray celebrate"), + ("👐", "open hands", "hug"), + ("🤲", "palms up together", "prayer"), + ("🤝", "handshake", "agreement deal"), + ("🙏", "folded hands", "prayer please thanks"), + ("✍️", "writing hand", "write"), + ("💪", "flexed biceps", "strong muscle"), + ("🦾", "mechanical arm", "robot prosthetic"), + ("🦵", "leg", "kick"), + ("🦶", "foot", "kick"), + ("👂", "ear", "listen hear"), + ("👃", "nose", "smell"), + ("🧠", "brain", "smart think"), + ("👀", "eyes", "look see watch"), + ("👁️", "eye", "see look"), + ("👅", "tongue", "taste lick"), + ("👄", "mouth", "lips kiss"), + // Hearts & Love + ("❤️", "red heart", "love"), + ("🧡", "orange heart", "love"), + ("💛", "yellow heart", "love friendship"), + ("💚", "green heart", "love"), + ("💙", "blue heart", "love"), + ("💜", "purple heart", "love"), + ("🖤", "black heart", "love dark"), + ("🤍", "white heart", "love pure"), + ("🤎", "brown heart", "love"), + ("💔", "broken heart", "heartbreak sad"), + ("❤️‍🔥", "heart on fire", "passion love"), + ("❤️‍🩹", "mending heart", "healing recovery"), + ("💕", "two hearts", "love"), + ("💞", "revolving hearts", "love"), + ("💓", "beating heart", "love"), + ("💗", "growing heart", "love"), + ("💖", "sparkling heart", "love"), + ("💘", "heart with arrow", "love cupid"), + ("💝", "heart with ribbon", "love gift"), + ("💟", "heart decoration", "love"), + // Animals + ("🐶", "dog face", "puppy"), + ("🐱", "cat face", "kitty"), + ("🐭", "mouse face", ""), + ("🐹", "hamster", ""), + ("🐰", "rabbit face", "bunny"), + ("🦊", "fox", ""), + ("🐻", "bear", ""), + ("🐼", "panda", ""), + ("🐨", "koala", ""), + ("🐯", "tiger face", ""), + ("🦁", "lion", ""), + ("🐮", "cow face", ""), + ("🐷", "pig face", ""), + ("🐸", "frog", ""), + ("🐵", "monkey face", ""), + ("🦄", "unicorn", "magic"), + ("🐝", "bee", "honeybee"), + ("🦋", "butterfly", ""), + ("🐌", "snail", "slow"), + ("🐛", "bug", "caterpillar"), + ("🦀", "crab", ""), + ("🐙", "octopus", ""), + ("🐠", "tropical fish", ""), + ("🐟", "fish", ""), + ("🐬", "dolphin", ""), + ("🐳", "whale", ""), + ("🦈", "shark", ""), + ("🐊", "crocodile", "alligator"), + ("🐢", "turtle", ""), + ("🦎", "lizard", ""), + ("🐍", "snake", ""), + ("🦖", "t-rex", "dinosaur"), + ("🦕", "sauropod", "dinosaur"), + ("🐔", "chicken", ""), + ("🐧", "penguin", ""), + ("🦅", "eagle", "bird"), + ("🦆", "duck", ""), + ("🦉", "owl", ""), + // Food & Drink + ("🍎", "red apple", "fruit"), + ("🍐", "pear", "fruit"), + ("🍊", "orange", "tangerine fruit"), + ("🍋", "lemon", "fruit"), + ("🍌", "banana", "fruit"), + ("🍉", "watermelon", "fruit"), + ("🍇", "grapes", "fruit"), + ("🍓", "strawberry", "fruit"), + ("🍒", "cherries", "fruit"), + ("🍑", "peach", "fruit"), + ("🥭", "mango", "fruit"), + ("🍍", "pineapple", "fruit"), + ("🥥", "coconut", "fruit"), + ("🥝", "kiwi", "fruit"), + ("🍅", "tomato", "vegetable"), + ("🥑", "avocado", ""), + ("🥦", "broccoli", "vegetable"), + ("🥬", "leafy green", "vegetable salad"), + ("🥒", "cucumber", "vegetable"), + ("🌶️", "hot pepper", "spicy chili"), + ("🌽", "corn", ""), + ("🥕", "carrot", "vegetable"), + ("🧄", "garlic", ""), + ("🧅", "onion", ""), + ("🥔", "potato", ""), + ("🍞", "bread", ""), + ("🥐", "croissant", ""), + ("🥖", "baguette", "bread french"), + ("🥨", "pretzel", ""), + ("🧀", "cheese", ""), + ("🥚", "egg", ""), + ("🍳", "cooking", "frying pan egg"), + ("🥞", "pancakes", "breakfast"), + ("🧇", "waffle", "breakfast"), + ("🥓", "bacon", "breakfast"), + ("🍔", "hamburger", "burger"), + ("🍟", "french fries", ""), + ("🍕", "pizza", ""), + ("🌭", "hot dog", ""), + ("🥪", "sandwich", ""), + ("🌮", "taco", "mexican"), + ("🌯", "burrito", "mexican"), + ("🍜", "steaming bowl", "ramen noodles"), + ("🍝", "spaghetti", "pasta"), + ("🍣", "sushi", "japanese"), + ("🍱", "bento box", "japanese"), + ("🍩", "doughnut", "donut dessert"), + ("🍪", "cookie", "dessert"), + ("🎂", "birthday cake", "dessert"), + ("🍰", "shortcake", "dessert"), + ("🧁", "cupcake", "dessert"), + ("🍫", "chocolate bar", "dessert"), + ("🍬", "candy", "sweet"), + ("🍭", "lollipop", "candy sweet"), + ("🍦", "soft ice cream", "dessert"), + ("🍨", "ice cream", "dessert"), + ("☕", "hot beverage", "coffee tea"), + ("🍵", "teacup", "tea"), + ("🧃", "juice box", ""), + ("🥤", "cup with straw", "soda drink"), + ("🍺", "beer mug", "drink alcohol"), + ("🍻", "clinking beer mugs", "cheers drink"), + ("🥂", "clinking glasses", "champagne cheers"), + ("🍷", "wine glass", "drink alcohol"), + ("🥃", "tumbler glass", "whiskey drink"), + ("🍸", "cocktail glass", "martini drink"), + // Objects & Symbols + ("💻", "laptop", "computer"), + ("🖥️", "desktop computer", "pc"), + ("⌨️", "keyboard", ""), + ("🖱️", "computer mouse", ""), + ("💾", "floppy disk", "save"), + ("💿", "optical disk", "cd"), + ("📱", "mobile phone", "smartphone"), + ("☎️", "telephone", "phone"), + ("📧", "email", "mail"), + ("📨", "incoming envelope", "email"), + ("📩", "envelope with arrow", "email send"), + ("📝", "memo", "note write"), + ("📄", "page facing up", "document"), + ("📃", "page with curl", "document"), + ("📑", "bookmark tabs", ""), + ("📚", "books", "library read"), + ("📖", "open book", "read"), + ("🔗", "link", "chain url"), + ("📎", "paperclip", "attachment"), + ("🔒", "locked", "security"), + ("🔓", "unlocked", "security open"), + ("🔑", "key", "password"), + ("🔧", "wrench", "tool fix"), + ("🔨", "hammer", "tool"), + ("⚙️", "gear", "settings"), + ("🧲", "magnet", ""), + ("💡", "light bulb", "idea"), + ("🔦", "flashlight", ""), + ("🔋", "battery", "power"), + ("🔌", "electric plug", "power"), + ("💰", "money bag", ""), + ("💵", "dollar", "money cash"), + ("💳", "credit card", "payment"), + ("⏰", "alarm clock", "time"), + ("⏱️", "stopwatch", "timer"), + ("📅", "calendar", "date"), + ("📆", "tear-off calendar", "date"), + ("✅", "check mark", "done yes"), + ("❌", "cross mark", "no wrong delete"), + ("❓", "question mark", "help"), + ("❗", "exclamation mark", "important warning"), + ("⚠️", "warning", "caution alert"), + ("🚫", "prohibited", "no ban forbidden"), + ("⭕", "hollow circle", ""), + ("🔴", "red circle", ""), + ("🟠", "orange circle", ""), + ("🟡", "yellow circle", ""), + ("🟢", "green circle", ""), + ("🔵", "blue circle", ""), + ("🟣", "purple circle", ""), + ("⚫", "black circle", ""), + ("⚪", "white circle", ""), + ("🟤", "brown circle", ""), + ("⬛", "black square", ""), + ("⬜", "white square", ""), + ("🔶", "large orange diamond", ""), + ("🔷", "large blue diamond", ""), + ("⭐", "star", "favorite"), + ("🌟", "glowing star", "sparkle"), + ("✨", "sparkles", "magic shine"), + ("💫", "dizzy", "star"), + ("🔥", "fire", "hot lit"), + ("💧", "droplet", "water"), + ("🌊", "wave", "water ocean"), + ("🎵", "musical note", "music"), + ("🎶", "musical notes", "music"), + ("🎤", "microphone", "sing karaoke"), + ("🎧", "headphones", "music"), + ("🎮", "video game", "gaming controller"), + ("🕹️", "joystick", "gaming"), + ("🎯", "direct hit", "target bullseye"), + ("🏆", "trophy", "winner award"), + ("🥇", "1st place medal", "gold winner"), + ("🥈", "2nd place medal", "silver"), + ("🥉", "3rd place medal", "bronze"), + ("🎁", "wrapped gift", "present"), + ("🎈", "balloon", "party"), + ("🎉", "party popper", "celebration tada"), + ("🎊", "confetti ball", "celebration"), + // Arrows & Misc + ("➡️", "right arrow", ""), + ("⬅️", "left arrow", ""), + ("⬆️", "up arrow", ""), + ("⬇️", "down arrow", ""), + ("↗️", "up-right arrow", ""), + ("↘️", "down-right arrow", ""), + ("↙️", "down-left arrow", ""), + ("↖️", "up-left arrow", ""), + ("↕️", "up-down arrow", ""), + ("↔️", "left-right arrow", ""), + ("🔄", "counterclockwise arrows", "refresh reload"), + ("🔃", "clockwise arrows", "refresh reload"), + ("➕", "plus", "add"), + ("➖", "minus", "subtract"), + ("➗", "division", "divide"), + ("✖️", "multiply", "times"), + ("♾️", "infinity", "forever"), + ("💯", "hundred points", "100 perfect"), + ("🆗", "ok button", "okay"), + ("🆕", "new button", ""), + ("🆓", "free button", ""), + ("ℹ️", "information", "info"), + ("🅿️", "parking", ""), + ("🚀", "rocket", "launch startup"), + ("✈️", "airplane", "travel flight"), + ("🚗", "car", "automobile"), + ("🚕", "taxi", "cab"), + ("🚌", "bus", ""), + ("🚂", "locomotive", "train"), + ("🏠", "house", "home"), + ("🏢", "office building", "work"), + ("🏥", "hospital", ""), + ("🏫", "school", ""), + ("🏛️", "classical building", ""), + ("⛪", "church", ""), + ("🕌", "mosque", ""), + ("🕍", "synagogue", ""), + ("🗽", "statue of liberty", "usa america"), + ("🗼", "tokyo tower", "japan"), + ("🗾", "map of japan", ""), + ("🌍", "globe europe-africa", "earth world"), + ("🌎", "globe americas", "earth world"), + ("🌏", "globe asia-australia", "earth world"), + ("🌑", "new moon", ""), + ("🌕", "full moon", ""), + ("☀️", "sun", "sunny"), + ("🌙", "crescent moon", "night"), + ("☁️", "cloud", ""), + ("🌧️", "cloud with rain", "rainy"), + ("⛈️", "cloud with lightning", "storm thunder"), + ("🌈", "rainbow", ""), + ("❄️", "snowflake", "cold winter"), + ("☃️", "snowman", "winter"), + ("🎄", "christmas tree", "xmas holiday"), + ("🎃", "jack-o-lantern", "halloween pumpkin"), + ("🐚", "shell", "beach"), + ("🌸", "cherry blossom", "flower spring"), + ("🌺", "hibiscus", "flower"), + ("🌻", "sunflower", "flower"), + ("🌹", "rose", "flower love"), + ("🌷", "tulip", "flower"), + ("🌱", "seedling", "plant grow"), + ("🌲", "evergreen tree", ""), + ("🌳", "deciduous tree", ""), + ("🌴", "palm tree", "tropical"), + ("🌵", "cactus", "desert"), + ("🍀", "four leaf clover", "luck irish"), + ("🍁", "maple leaf", "fall autumn canada"), + ("🍂", "fallen leaf", "fall autumn"), +]; + +pub struct EmojiProvider { + items: Vec, +} + +impl Default for EmojiProvider { + fn default() -> Self { + Self::new() + } +} + +impl EmojiProvider { + pub fn new() -> Self { + Self { items: Vec::new() } + } + + fn build_items() -> Vec { + EMOJIS + .iter() + .map(|(emoji, name, keywords)| { + let mut tags = vec![name.to_string()]; + if !keywords.is_empty() { + tags.push(keywords.to_string()); + } + + LaunchItem { + id: format!("emoji:{}", emoji), + name: name.to_string(), + description: Some(format!("{} {}", emoji, keywords)), + icon: Some((*emoji).to_string()), + provider: ProviderType::Plugin(TYPE_ID.into()), + command: format!("printf '%s' '{}' | wl-copy", emoji), + terminal: false, + tags, + source: ItemSource::Core, + } + }) + .collect() + } +} + +impl Provider for EmojiProvider { + fn name(&self) -> &str { + "Emoji" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(TYPE_ID.into()) + } + + fn refresh(&mut self) { + self.items = Self::build_items(); + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } + + fn prefix(&self) -> Option<&str> { + Some(":emoji") + } + + fn icon(&self) -> &str { + "face-smile" + } + + fn tab_label(&self) -> Option<&str> { + Some("Emoji") + } + + fn search_noun(&self) -> Option<&str> { + Some("emoji") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_emoji_state_new() { + let mut provider = EmojiProvider::new(); + provider.refresh(); + assert!( + provider.items().len() > 100, + "Should have more than 100 emojis loaded after refresh" + ); + } + + #[test] + fn test_emoji_has_grinning_face() { + let mut provider = EmojiProvider::new(); + provider.refresh(); + + let grinning = provider + .items() + .iter() + .find(|i| i.name == "grinning face"); + assert!(grinning.is_some()); + + let item = grinning.unwrap(); + assert!(item.description.as_ref().unwrap().contains("😀")); + } + + #[test] + fn test_emoji_command_format() { + let mut provider = EmojiProvider::new(); + provider.refresh(); + + let item = &provider.items()[0]; + assert!(item.command.contains("wl-copy")); + assert!(item.command.contains("printf")); + } + + #[test] + fn test_emojis_have_keywords() { + let mut provider = EmojiProvider::new(); + provider.refresh(); + + let heart = provider.items().iter().find(|i| i.name == "red heart"); + assert!(heart.is_some()); + } + + #[test] + fn provider_type_is_emoji_plugin() { + let provider = EmojiProvider::new(); + assert_eq!( + provider.provider_type(), + ProviderType::Plugin("emoji".into()) + ); + } +} diff --git a/crates/owlry/src/providers/filesearch.rs b/crates/owlry/src/providers/filesearch.rs new file mode 100644 index 0000000..602ec36 --- /dev/null +++ b/crates/owlry/src/providers/filesearch.rs @@ -0,0 +1,267 @@ +//! File search provider. +//! +//! Dynamic provider that searches for files using `fd` (preferred) or +//! `locate`. Triggered by: +//! - `/ name` / `/name` (slash prefix) +//! - `file name` / `find name` (word prefix) +//! +//! External dependencies: +//! - `fd` (preferred) or `locate` + +use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType}; +use std::path::Path; +use std::process::Command; + +const TYPE_ID: &str = "filesearch"; +const MAX_RESULTS: usize = 20; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SearchTool { + Fd, + Locate, + None, +} + +/// Dynamic file search provider — shells out to `fd` or `locate` per keystroke. +pub(crate) struct FileSearchProvider { + search_tool: SearchTool, + // TODO(v2.x): plumb via constructor (search roots, extra flags). + home: String, +} + +impl Default for FileSearchProvider { + fn default() -> Self { + Self::new() + } +} + +impl FileSearchProvider { + pub fn new() -> Self { + let search_tool = Self::detect_search_tool(); + // TODO(v2.x): plumb via constructor. + let home = dirs::home_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "/".to_string()); + + Self { search_tool, home } + } + + fn detect_search_tool() -> SearchTool { + // Prefer fd (faster, respects .gitignore). + if Self::command_exists("fd") { + return SearchTool::Fd; + } + // Fall back to locate (requires updatedb). + if Self::command_exists("locate") { + return SearchTool::Locate; + } + SearchTool::None + } + + fn command_exists(cmd: &str) -> bool { + Command::new("which") + .arg(cmd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + /// Extract the search term from the query. + fn extract_search_term(query: &str) -> Option<&str> { + let trimmed = query.trim(); + + if let Some(rest) = trimmed.strip_prefix("/ ") { + Some(rest.trim()) + } else if let Some(rest) = trimmed.strip_prefix('/') { + Some(rest.trim()) + } else { + // Handle "file " and "find " prefixes (case-insensitive), or raw + // query in filter mode. + let lower = trimmed.to_lowercase(); + if lower.starts_with("file ") || lower.starts_with("find ") { + Some(trimmed[5..].trim()) + } else { + Some(trimmed) + } + } + } + + fn evaluate(&self, query: &str) -> Vec { + let search_term = match Self::extract_search_term(query) { + Some(t) if !t.is_empty() => t, + _ => return Vec::new(), + }; + + self.search_files(search_term) + } + + fn search_files(&self, pattern: &str) -> Vec { + match self.search_tool { + SearchTool::Fd => self.search_with_fd(pattern), + SearchTool::Locate => self.search_with_locate(pattern), + SearchTool::None => Vec::new(), + } + } + + fn search_with_fd(&self, pattern: &str) -> Vec { + let output = match Command::new("fd") + .args([ + "--max-results", + &MAX_RESULTS.to_string(), + "--type", + "f", // Files only + "--type", + "d", // And directories + pattern, + ]) + .current_dir(&self.home) + .output() + { + Ok(o) => o, + Err(_) => return Vec::new(), + }; + + self.parse_file_results(&String::from_utf8_lossy(&output.stdout)) + } + + fn search_with_locate(&self, pattern: &str) -> Vec { + let output = match Command::new("locate") + .args([ + "--limit", + &MAX_RESULTS.to_string(), + "--ignore-case", + pattern, + ]) + .output() + { + Ok(o) => o, + Err(_) => return Vec::new(), + }; + + self.parse_file_results(&String::from_utf8_lossy(&output.stdout)) + } + + fn parse_file_results(&self, output: &str) -> Vec { + output + .lines() + .filter(|line| !line.is_empty()) + .map(|path| { + let path = path.trim(); + let full_path = if path.starts_with('/') { + path.to_string() + } else { + format!("{}/{}", self.home, path) + }; + + let filename = Path::new(&full_path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| full_path.clone()); + + let is_dir = Path::new(&full_path).is_dir(); + let icon = if is_dir { "folder" } else { "text-x-generic" }; + + let command = format!("xdg-open '{}'", full_path.replace('\'', "'\\''")); + + LaunchItem { + id: format!("file:{}", full_path), + name: filename, + description: Some(full_path.clone()), + icon: Some(icon.to_string()), + provider: ProviderType::Plugin(TYPE_ID.into()), + command, + terminal: false, + tags: vec!["file".to_string()], + source: ItemSource::Core, + } + }) + .collect() + } +} + +impl DynamicProvider for FileSearchProvider { + fn name(&self) -> &str { + "Files" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(TYPE_ID.into()) + } + + fn priority(&self) -> u32 { + 8_000 + } + + fn query(&self, query: &str) -> Vec { + self.evaluate(query) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_search_term() { + assert_eq!( + FileSearchProvider::extract_search_term("/ config.toml"), + Some("config.toml") + ); + assert_eq!( + FileSearchProvider::extract_search_term("/config"), + Some("config") + ); + assert_eq!( + FileSearchProvider::extract_search_term("file bashrc"), + Some("bashrc") + ); + assert_eq!( + FileSearchProvider::extract_search_term("find readme"), + Some("readme") + ); + } + + #[test] + fn test_extract_search_term_empty() { + assert_eq!(FileSearchProvider::extract_search_term("/"), Some("")); + assert_eq!(FileSearchProvider::extract_search_term("/ "), Some("")); + } + + #[test] + fn test_command_exists() { + // 'which' should exist on any Unix system. + assert!(FileSearchProvider::command_exists("which")); + // This should not exist. + assert!(!FileSearchProvider::command_exists( + "nonexistent-command-12345" + )); + } + + #[test] + fn test_detect_search_tool() { + // Just ensure it doesn't panic. + let _ = FileSearchProvider::detect_search_tool(); + } + + #[test] + fn test_state_new() { + let provider = FileSearchProvider::new(); + assert!(!provider.home.is_empty()); + } + + #[test] + fn test_evaluate_empty() { + let provider = FileSearchProvider::new(); + let results = provider.evaluate("/"); + assert!(results.is_empty()); + + let results = provider.evaluate("/ "); + assert!(results.is_empty()); + } + + #[test] + fn provider_type_is_filesearch_plugin() { + let p = FileSearchProvider::new(); + assert_eq!(p.provider_type(), ProviderType::Plugin("filesearch".into())); + } +} diff --git a/crates/owlry/src/providers/mod.rs b/crates/owlry/src/providers/mod.rs index aa9549c..257db2d 100644 --- a/crates/owlry/src/providers/mod.rs +++ b/crates/owlry/src/providers/mod.rs @@ -7,8 +7,20 @@ pub(crate) mod converter; pub(crate) mod system; // Optional feature-gated providers +#[cfg(feature = "bookmarks")] +pub(crate) mod bookmarks; +#[cfg(feature = "clipboard")] +pub(crate) mod clipboard; +#[cfg(feature = "emoji")] +pub(crate) mod emoji; +#[cfg(feature = "filesearch")] +pub(crate) mod filesearch; +#[cfg(feature = "ssh")] +pub(crate) mod ssh; #[cfg(feature = "systemd")] pub(crate) mod systemd; +#[cfg(feature = "websearch")] +pub(crate) mod websearch; // Re-exports for core providers pub use application::ApplicationProvider; @@ -243,45 +255,69 @@ impl ProviderManager { /// Only built-in / compiled-in providers are registered here. Future Lua-defined /// providers (Phase 3+) are added via [`Self::add_provider`] after construction. pub fn new_with_config(config: Arc>) -> Self { - let (calc_enabled, conv_enabled, sys_enabled, systemd_enabled) = match config.read() { - Ok(cfg) => ( - cfg.providers.calculator, - cfg.providers.converter, - cfg.providers.system, - cfg.providers.systemd, - ), + let cfg_snapshot = match config.read() { + Ok(cfg) => cfg.providers.clone(), Err(_) => { log::warn!("Config lock poisoned during provider init; using defaults"); - (true, true, true, true) + crate::config::ProvidersConfig::default() } }; - let _ = systemd_enabled; // referenced only behind cfg(feature) let mut core_providers: Vec> = vec![ Box::new(ApplicationProvider::new()), Box::new(CommandProvider::new()), ]; - if sys_enabled { + if cfg_snapshot.system { core_providers.push(Box::new(system::SystemProvider::new())); info!("Registered built-in system provider"); } + #[cfg(feature = "bookmarks")] + if cfg_snapshot.bookmarks { + core_providers.push(Box::new(bookmarks::BookmarksProvider::new())); + info!("Registered bookmarks provider"); + } + #[cfg(feature = "clipboard")] + if cfg_snapshot.clipboard { + core_providers.push(Box::new(clipboard::ClipboardProvider::new())); + info!("Registered clipboard provider"); + } + #[cfg(feature = "emoji")] + if cfg_snapshot.emoji { + core_providers.push(Box::new(emoji::EmojiProvider::new())); + info!("Registered emoji provider"); + } + #[cfg(feature = "ssh")] + if cfg_snapshot.ssh { + core_providers.push(Box::new(ssh::SshProvider::new())); + info!("Registered ssh provider"); + } #[cfg(feature = "systemd")] - if systemd_enabled { + if cfg_snapshot.systemd { core_providers.push(Box::new(systemd::SystemdProvider::new())); info!("Registered systemd provider (type_id: uuctl)"); } let mut builtin_dynamic: Vec> = Vec::new(); - if calc_enabled { + if cfg_snapshot.calculator { builtin_dynamic.push(Box::new(calculator::CalculatorProvider)); info!("Registered built-in calculator provider"); } - if conv_enabled { + if cfg_snapshot.converter { builtin_dynamic.push(Box::new(converter::ConverterProvider::new())); info!("Registered built-in converter provider"); } + #[cfg(feature = "filesearch")] + if cfg_snapshot.filesearch { + builtin_dynamic.push(Box::new(filesearch::FileSearchProvider::new())); + info!("Registered filesearch provider"); + } + #[cfg(feature = "websearch")] + if cfg_snapshot.websearch { + builtin_dynamic.push(Box::new(websearch::WebSearchProvider::new())); + info!("Registered websearch provider"); + } Self::new(core_providers, builtin_dynamic) } diff --git a/crates/owlry/src/providers/ssh.rs b/crates/owlry/src/providers/ssh.rs new file mode 100644 index 0000000..d2d11a4 --- /dev/null +++ b/crates/owlry/src/providers/ssh.rs @@ -0,0 +1,290 @@ +//! SSH hosts provider. +//! +//! Parses `~/.ssh/config` and exposes each non-wildcard `Host` entry as a +//! launchable item that opens an `ssh ` session in the user's terminal. + +use super::{ItemSource, LaunchItem, Provider, ProviderType}; +use std::fs; +use std::path::PathBuf; + +const TYPE_ID: &str = "ssh"; +const ICON: &str = "utilities-terminal"; + +pub struct SshProvider { + items: Vec, + terminal_command: String, +} + +impl Default for SshProvider { + fn default() -> Self { + Self::new() + } +} + +impl SshProvider { + pub fn new() -> Self { + Self { + items: Vec::new(), + terminal_command: Self::load_terminal_from_config(), + } + } + + 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::() + && 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() + } + + fn ssh_config_path() -> Option { + dirs::home_dir().map(|h| h.join(".ssh").join("config")) + } + + fn parse_ssh_config(&mut self) { + self.items.clear(); + + let config_path = match Self::ssh_config_path() { + Some(p) => p, + None => return, + }; + + if !config_path.exists() { + return; + } + + let content = match fs::read_to_string(&config_path) { + Ok(c) => c, + Err(_) => return, + }; + + let mut current_host: Option = None; + let mut current_hostname: Option = None; + let mut current_user: Option = None; + let mut current_port: Option = None; + + for line in content.lines() { + let line = line.trim(); + + // Skip comments and empty lines + if line.is_empty() || line.starts_with('#') { + continue; + } + + // Split on whitespace or '=' + let parts: Vec<&str> = line + .splitn(2, |c: char| c.is_whitespace() || c == '=') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + + if parts.len() < 2 { + continue; + } + + let key = parts[0].to_lowercase(); + let value = parts[1]; + + match key.as_str() { + "host" => { + // Save previous host if exists + if let Some(host) = current_host.take() { + self.add_host_item( + &host, + current_hostname.take(), + current_user.take(), + current_port.take(), + ); + } + + // Skip wildcards and patterns + if !value.contains('*') && !value.contains('?') && value != "*" { + current_host = Some(value.to_string()); + } + current_hostname = None; + current_user = None; + current_port = None; + } + "hostname" => { + current_hostname = Some(value.to_string()); + } + "user" => { + current_user = Some(value.to_string()); + } + "port" => { + current_port = Some(value.to_string()); + } + _ => {} + } + } + + // Don't forget the last host + if let Some(host) = current_host.take() { + self.add_host_item(&host, current_hostname, current_user, current_port); + } + } + + fn add_host_item( + &mut self, + host: &str, + hostname: Option, + user: Option, + port: Option, + ) { + // Build description + let mut desc_parts = Vec::new(); + if let Some(ref h) = hostname { + desc_parts.push(h.clone()); + } + if let Some(ref u) = user { + desc_parts.push(format!("user: {}", u)); + } + if let Some(ref p) = port { + desc_parts.push(format!("port: {}", p)); + } + + let description = if desc_parts.is_empty() { + None + } else { + Some(desc_parts.join(", ")) + }; + + // Build SSH command - just use the host alias, SSH will resolve the rest + let ssh_command = format!("ssh {}", host); + + // Wrap in terminal + let command = format!("{} -e {}", self.terminal_command, ssh_command); + + self.items.push(LaunchItem { + id: format!("ssh:{}", host), + name: format!("SSH: {}", host), + description, + icon: Some(ICON.to_string()), + provider: ProviderType::Plugin(TYPE_ID.into()), + command, + terminal: false, + tags: vec!["ssh".to_string(), "remote".to_string()], + source: ItemSource::Core, + }); + } +} + +impl Provider for SshProvider { + fn name(&self) -> &str { + "SSH" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(TYPE_ID.into()) + } + + fn refresh(&mut self) { + self.parse_ssh_config(); + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } + + fn prefix(&self) -> Option<&str> { + Some(":ssh") + } + + fn icon(&self) -> &str { + ICON + } + + fn tab_label(&self) -> Option<&str> { + Some("SSH") + } + + fn search_noun(&self) -> Option<&str> { + Some("SSH hosts") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ssh_provider_new() { + let p = SshProvider::new(); + assert!(p.items.is_empty()); + } + + #[test] + fn test_parse_simple_config() { + let mut p = SshProvider::new(); + + // We can't easily test the full flow without mocking file paths, + // but we can test the add_host_item method + p.add_host_item( + "myserver", + Some("192.168.1.100".to_string()), + Some("admin".to_string()), + Some("2222".to_string()), + ); + + assert_eq!(p.items.len(), 1); + assert_eq!(p.items[0].name, "SSH: myserver"); + assert!(p.items[0].command.contains("ssh myserver")); + } + + #[test] + fn test_add_host_without_details() { + let mut p = SshProvider::new(); + p.add_host_item("simple-host", None, None, None); + + assert_eq!(p.items.len(), 1); + assert_eq!(p.items[0].name, "SSH: simple-host"); + assert!(p.items[0].description.is_none()); + } + + #[test] + fn test_add_host_with_partial_details() { + let mut p = SshProvider::new(); + p.add_host_item("partial", Some("example.com".to_string()), None, None); + + assert_eq!(p.items.len(), 1); + let desc = p.items[0].description.as_ref().unwrap(); + assert_eq!(desc, "example.com"); + } + + #[test] + fn test_items_have_icons() { + let mut p = SshProvider::new(); + p.add_host_item("test", None, None, None); + + assert!(p.items[0].icon.is_some()); + assert_eq!(p.items[0].icon.as_ref().unwrap(), ICON); + } + + #[test] + fn test_items_have_keywords() { + let mut p = SshProvider::new(); + p.add_host_item("test", None, None, None); + + assert!(!p.items[0].tags.is_empty()); + assert!(p.items[0].tags.iter().any(|t| t == "ssh")); + } + + #[test] + fn provider_type_is_ssh_plugin() { + let p = SshProvider::new(); + assert_eq!(p.provider_type(), ProviderType::Plugin("ssh".into())); + } +} diff --git a/crates/owlry/src/providers/websearch.rs b/crates/owlry/src/providers/websearch.rs new file mode 100644 index 0000000..d08fa4d --- /dev/null +++ b/crates/owlry/src/providers/websearch.rs @@ -0,0 +1,244 @@ +use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType}; + +/// Built-in web search provider. Opens a search URL in the user's browser via +/// `xdg-open`. +/// +/// Triggered by: +/// - `? query` / `?query` (explicit prefix) +/// - `web query` / `search query` (word prefixes) +/// +/// The CLI prefix routing for `:web` and `?` is handled by core; this provider +/// only needs to return matching items when `query()` is called. +pub(crate) struct WebSearchProvider { + /// URL template containing a `{query}` placeholder. + url_template: String, +} + +/// Common search engine URL templates. `{query}` is replaced with the +/// URL-encoded search term. +const SEARCH_ENGINES: &[(&str, &str)] = &[ + ("google", "https://www.google.com/search?q={query}"), + ("duckduckgo", "https://duckduckgo.com/?q={query}"), + ("bing", "https://www.bing.com/search?q={query}"), + ("startpage", "https://www.startpage.com/search?q={query}"), + ("searxng", "https://searx.be/search?q={query}"), + ("brave", "https://search.brave.com/search?q={query}"), + ("ecosia", "https://www.ecosia.org/search?q={query}"), +]; + +/// Default search engine when no configuration is provided. +const DEFAULT_ENGINE: &str = "duckduckgo"; + +const PROVIDER_ICON: &str = "web-browser"; + +impl Default for WebSearchProvider { + fn default() -> Self { + // TODO(v2.x): plumb search_engine via constructor argument from Lua + // config. The C-ABI host config lookup was removed; for now we + // hardcode the default engine. + Self::with_engine(DEFAULT_ENGINE) + } +} + +impl WebSearchProvider { + #[allow(dead_code)] + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn with_engine(engine_name: &str) -> Self { + let lower = engine_name.to_lowercase(); + let url_template = SEARCH_ENGINES + .iter() + .find(|(name, _)| *name == lower) + .map(|(_, url)| (*url).to_string()) + .unwrap_or_else(|| { + // Not a known engine; treat as custom URL template if it + // contains a {query} placeholder, else fall back to default. + if engine_name.contains("{query}") { + engine_name.to_string() + } else { + SEARCH_ENGINES + .iter() + .find(|(name, _)| *name == DEFAULT_ENGINE) + .map(|(_, url)| (*url).to_string()) + .expect("default engine must exist in SEARCH_ENGINES") + } + }); + + Self { url_template } + } + + /// Extract the search term from a raw query string. + fn extract_search_term(query: &str) -> Option<&str> { + let trimmed = query.trim(); + + if let Some(rest) = trimmed.strip_prefix("? ") { + Some(rest.trim()) + } else if let Some(rest) = trimmed.strip_prefix('?') { + Some(rest.trim()) + } else if trimmed.to_lowercase().starts_with("web ") { + Some(trimmed[4..].trim()) + } else if trimmed.to_lowercase().starts_with("search ") { + Some(trimmed[7..].trim()) + } else { + // Filter mode: accept the raw query. + Some(trimmed) + } + } + + /// URL-encode a search query using a small percent-encoding scheme suitable + /// for query parameters (spaces become `+`). + fn url_encode(query: &str) -> String { + query + .chars() + .map(|c| match c { + ' ' => "+".to_string(), + '&' => "%26".to_string(), + '=' => "%3D".to_string(), + '?' => "%3F".to_string(), + '#' => "%23".to_string(), + '+' => "%2B".to_string(), + '%' => "%25".to_string(), + c if c.is_ascii_alphanumeric() || "-_.~".contains(c) => c.to_string(), + c => format!("%{:02X}", c as u32), + }) + .collect() + } + + /// Build the final search URL for a given search term. + fn build_search_url(&self, search_term: &str) -> String { + let encoded = Self::url_encode(search_term); + self.url_template.replace("{query}", &encoded) + } + + /// Evaluate a query and produce a single `LaunchItem` if the query yields a + /// non-empty search term. + fn evaluate(&self, query: &str) -> Option { + let search_term = Self::extract_search_term(query)?; + if search_term.is_empty() { + return None; + } + + let url = self.build_search_url(search_term); + let command = format!("xdg-open '{}'", url.replace('\'', "'\\''")); + + Some(LaunchItem { + id: format!("websearch:{}", search_term), + name: format!("Search: {}", search_term), + description: Some("Open in browser".to_string()), + icon: Some(PROVIDER_ICON.to_string()), + provider: ProviderType::Plugin("websearch".into()), + command, + terminal: false, + tags: vec!["web".into(), "search".into()], + source: ItemSource::Core, + }) + } +} + +impl DynamicProvider for WebSearchProvider { + fn name(&self) -> &str { + "Web Search" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin("websearch".into()) + } + + fn priority(&self) -> u32 { + 9_000 + } + + fn query(&self, query: &str) -> Vec { + match self.evaluate(query) { + Some(item) => vec![item], + None => Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_search_term() { + assert_eq!( + WebSearchProvider::extract_search_term("? rust programming"), + Some("rust programming") + ); + assert_eq!( + WebSearchProvider::extract_search_term("?rust"), + Some("rust") + ); + assert_eq!( + WebSearchProvider::extract_search_term("web rust docs"), + Some("rust docs") + ); + assert_eq!( + WebSearchProvider::extract_search_term("search how to rust"), + Some("how to rust") + ); + } + + #[test] + fn test_url_encode() { + assert_eq!(WebSearchProvider::url_encode("hello world"), "hello+world"); + assert_eq!(WebSearchProvider::url_encode("foo&bar"), "foo%26bar"); + assert_eq!(WebSearchProvider::url_encode("a=b"), "a%3Db"); + assert_eq!(WebSearchProvider::url_encode("test?query"), "test%3Fquery"); + } + + #[test] + fn test_build_search_url() { + let provider = WebSearchProvider::with_engine("duckduckgo"); + let url = provider.build_search_url("rust programming"); + assert_eq!(url, "https://duckduckgo.com/?q=rust+programming"); + } + + #[test] + fn test_build_search_url_google() { + let provider = WebSearchProvider::with_engine("google"); + let url = provider.build_search_url("rust"); + assert_eq!(url, "https://www.google.com/search?q=rust"); + } + + #[test] + fn test_evaluate() { + let provider = WebSearchProvider::new(); + let item = provider.evaluate("? rust docs").unwrap(); + assert_eq!(item.name, "Search: rust docs"); + assert!(item.command.contains("xdg-open")); + assert!(item.command.contains("duckduckgo")); + } + + #[test] + fn test_evaluate_empty() { + let provider = WebSearchProvider::new(); + assert!(provider.evaluate("?").is_none()); + assert!(provider.evaluate("? ").is_none()); + } + + #[test] + fn test_custom_url_template() { + let provider = WebSearchProvider::with_engine("https://custom.search/q={query}"); + let url = provider.build_search_url("test"); + assert_eq!(url, "https://custom.search/q=test"); + } + + #[test] + fn test_fallback_to_default() { + let provider = WebSearchProvider::with_engine("nonexistent"); + let url = provider.build_search_url("test"); + assert!(url.contains("duckduckgo")); + } + + #[test] + fn provider_type_is_websearch_plugin() { + assert_eq!( + WebSearchProvider::default().provider_type(), + ProviderType::Plugin("websearch".into()) + ); + } +} diff --git a/docs/RESTRUCTURE-V2.md b/docs/RESTRUCTURE-V2.md index 1c65885..560ae40 100644 --- a/docs/RESTRUCTURE-V2.md +++ b/docs/RESTRUCTURE-V2.md @@ -452,7 +452,9 @@ This section captures in-progress state. Update freely as work proceeds. - `2fc976b` — D15–D21 resolutions - `ae4a903` — C-ABI demolition: tasks #3/#4/#5 done in one commit - `1d20754` — TDD characterization pass (+36 tests) - - (next) — Workspace collapse: owlry-core merged into owlry (task #2) + - `0a4a090` — Workspace collapse: owlry-core merged into owlry (task #2) + - `eb8a65f` — systemd provider converted (issue #5 functional fix in v2) + - (next) — Remaining 6 plugins converted in parallel: bookmarks, clipboard, emoji, filesearch, ssh, websearch (tasks #6 + #7) - **Tasks done:** #1 inventory, #3 delete C-ABI, #4 delete Rune+Lua crates, #5 delete config_editor (scripts never lived in this repo) - **Tasks remaining (Phase 1):** #2 workspace collapse, #6 convert 8 plugins, #7 cargo features, #8 sys→power rename, #9 CLI subcommands, #10 auto-mode test, #11 final build+smoke - **Stray processes from inventory phase:** From d1c327002b414b559b12e594a828fe612daafe0e Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 02:23:13 +0200 Subject: [PATCH 08/23] refactor(power): rename sys provider to power (D13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per D13 in the v2 plan: 'sys' collides mentally with 'systemd'. The power & session provider becomes 'power' everywhere. Pre-v2 names ('sys', 'system', badge_sys) remain accepted as serde aliases so existing user configs keep parsing. Also fixes a pre-existing bug: filter.rs mapped :sys/:system/:power prefixes to the type_id 'system', but the provider exposed itself as Plugin('sys'). The two never matched, so prefix filtering on this provider was a no-op. Now everything (filter table, ProviderType FromStr, provider's own provider_type, config key) agrees on 'power'. Files renamed: providers/system.rs -> providers/power.rs Struct renamed: SystemProvider -> PowerProvider type_id: 'sys' -> 'power' Item IDs: 'sys:shutdown' -> 'power:shutdown' (frecency for the 7 power items resets after upgrade — acceptable for a v2 break) Config key: providers.system -> providers.power (alias 'system', 'sys') Theme color: colors.badge_sys -> colors.badge_power (alias 'badge_sys'). theme.rs emits both --owlry-badge-power and --owlry-badge-sys so existing stylesheets keep rendering. UI provider_meta: 'system' arm becomes 'power' | 'system' ProviderType::FromStr: 'power', 'sys', 'system' all -> Plugin('power') (and 'uuctl', 'systemd' -> Plugin('uuctl') as parallel hygiene) Tests added (TDD): - provider_type_from_str_maps_power_aliases - provider_type_from_str_maps_systemd_aliases - providers_config_accepts_power_key - providers_config_accepts_pre_v2_system_alias - theme_colors_accepts_pre_v2_badge_sys_alias - all_item_ids_use_power_prefix (in power.rs) 239 tests pass (up from 234) with --features full. Task #8 complete. --- crates/owlry/src/config/mod.rs | 47 +++++++++-- crates/owlry/src/filter.rs | 6 +- crates/owlry/src/providers/mod.rs | 47 ++++++++++- .../src/providers/{system.rs => power.rs} | 77 ++++++++++++++----- crates/owlry/src/theme.rs | 6 +- crates/owlry/src/ui/main_window.rs | 6 +- crates/owlry/src/ui/provider_meta.rs | 8 +- 7 files changed, 156 insertions(+), 41 deletions(-) rename crates/owlry/src/providers/{system.rs => power.rs} (62%) diff --git a/crates/owlry/src/config/mod.rs b/crates/owlry/src/config/mod.rs index 48d158a..ecde8c6 100644 --- a/crates/owlry/src/config/mod.rs +++ b/crates/owlry/src/config/mod.rs @@ -103,7 +103,8 @@ pub struct ThemeColors { pub badge_file: Option, pub badge_script: Option, pub badge_ssh: Option, - pub badge_sys: Option, + #[serde(alias = "badge_sys")] + pub badge_power: Option, pub badge_uuctl: Option, pub badge_web: Option, // Widget badge colors @@ -168,9 +169,10 @@ pub struct ProvidersConfig { /// Enable built-in unit/currency converter (> trigger) #[serde(default = "default_true")] pub converter: bool, - /// Enable built-in system actions (shutdown, reboot, lock, etc.) - #[serde(default = "default_true")] - pub system: bool, + /// Enable built-in power actions (shutdown, reboot, lock, etc.) + /// Pre-v2 config key `system` is still accepted as an alias. + #[serde(default = "default_true", alias = "system", alias = "sys")] + pub power: bool, /// Enable systemd user units provider (alias: uuctl) #[serde(default = "default_true", alias = "uuctl")] pub systemd: bool, @@ -212,7 +214,7 @@ impl Default for ProvidersConfig { commands: true, calculator: true, converter: true, - system: true, + power: true, systemd: true, bookmarks: true, clipboard: true, @@ -612,3 +614,38 @@ mod tests { assert!(!super::command_exists("owlry_nonexistent_binary_abc123")); } } + +#[cfg(test)] +mod v2_rename_tests { + use super::*; + + #[test] + fn providers_config_accepts_power_key() { + let toml = r#"[providers] +power = false +"#; + let cfg: Config = toml::from_str(toml).expect("must parse"); + assert!(!cfg.providers.power); + } + + #[test] + fn providers_config_accepts_pre_v2_system_alias() { + // Pre-v2 configs used `system = ...`. Serde alias keeps them working. + let toml = r#"[providers] +system = false +"#; + let cfg: Config = toml::from_str(toml).expect("must parse"); + assert!(!cfg.providers.power); + } + + #[test] + fn theme_colors_accepts_pre_v2_badge_sys_alias() { + // Pre-v2 stylesheets named the color `badge_sys`. Serde alias keeps + // existing user configs working. + let toml = r##"[appearance.colors] +badge_sys = "#ff8800" +"##; + let cfg: Config = toml::from_str(toml).expect("must parse"); + assert_eq!(cfg.appearance.colors.badge_power.as_deref(), Some("#ff8800")); + } +} diff --git a/crates/owlry/src/filter.rs b/crates/owlry/src/filter.rs index 1f4f0c4..90973d8 100644 --- a/crates/owlry/src/filter.rs +++ b/crates/owlry/src/filter.rs @@ -241,9 +241,9 @@ impl ProviderFilter { ("script", "scripts"), ("scripts", "scripts"), ("ssh", "ssh"), - ("sys", "system"), - ("system", "system"), - ("power", "system"), + ("sys", "power"), + ("system", "power"), + ("power", "power"), ("uuctl", "uuctl"), ("systemd", "uuctl"), ("web", "websearch"), diff --git a/crates/owlry/src/providers/mod.rs b/crates/owlry/src/providers/mod.rs index 257db2d..37381ab 100644 --- a/crates/owlry/src/providers/mod.rs +++ b/crates/owlry/src/providers/mod.rs @@ -4,7 +4,7 @@ mod command; pub mod dmenu; pub(crate) mod calculator; pub(crate) mod converter; -pub(crate) mod system; +pub(crate) mod power; // Optional feature-gated providers #[cfg(feature = "bookmarks")] @@ -137,6 +137,10 @@ impl std::str::FromStr for ProviderType { "app" | "apps" | "application" | "applications" => Ok(ProviderType::Application), "cmd" | "cmds" | "command" | "commands" => Ok(ProviderType::Command), "dmenu" => Ok(ProviderType::Dmenu), + // Power provider — `sys` and `system` are pre-v2 muscle-memory aliases. + "power" | "sys" | "system" => Ok(ProviderType::Plugin("power".into())), + // systemd provider — type_id is `uuctl` (CLI back-compat). + "uuctl" | "systemd" => Ok(ProviderType::Plugin("uuctl".into())), other => Ok(ProviderType::Plugin(other.to_string())), } } @@ -268,9 +272,9 @@ impl ProviderManager { Box::new(CommandProvider::new()), ]; - if cfg_snapshot.system { - core_providers.push(Box::new(system::SystemProvider::new())); - info!("Registered built-in system provider"); + if cfg_snapshot.power { + core_providers.push(Box::new(power::PowerProvider::new())); + info!("Registered built-in power provider"); } #[cfg(feature = "bookmarks")] @@ -937,6 +941,41 @@ mod tests { assert_eq!(ProviderPosition::Widget.as_str(), "widget"); } + #[test] + fn provider_type_from_str_maps_power_aliases() { + // v2 renamed `sys` -> `power`. `sys` and `system` are kept as aliases + // for muscle memory; they must all resolve to Plugin("power"), never + // Plugin("sys") or Plugin("system"). + use std::str::FromStr; + assert_eq!( + ProviderType::from_str("power").unwrap(), + ProviderType::Plugin("power".into()) + ); + assert_eq!( + ProviderType::from_str("sys").unwrap(), + ProviderType::Plugin("power".into()) + ); + assert_eq!( + ProviderType::from_str("system").unwrap(), + ProviderType::Plugin("power".into()) + ); + } + + #[test] + fn provider_type_from_str_maps_systemd_aliases() { + // systemd provider's type_id is `uuctl` (CLI back-compat from pre-v2). + // Both `uuctl` and `systemd` must resolve to Plugin("uuctl"). + use std::str::FromStr; + assert_eq!( + ProviderType::from_str("uuctl").unwrap(), + ProviderType::Plugin("uuctl".into()) + ); + assert_eq!( + ProviderType::from_str("systemd").unwrap(), + ProviderType::Plugin("uuctl".into()) + ); + } + #[test] fn provider_type_from_str_accepts_plural_aliases() { // After the demolition, FromStr accepts both singular and plural aliases. diff --git a/crates/owlry/src/providers/system.rs b/crates/owlry/src/providers/power.rs similarity index 62% rename from crates/owlry/src/providers/system.rs rename to crates/owlry/src/providers/power.rs index a7d3c6f..9003bf9 100644 --- a/crates/owlry/src/providers/system.rs +++ b/crates/owlry/src/providers/power.rs @@ -1,13 +1,20 @@ use super::{ItemSource, LaunchItem, Provider, ProviderType}; -/// Built-in system provider. Returns a fixed set of power and session management actions. +/// Built-in power & session provider. Returns a fixed set of shutdown / reboot / +/// suspend / lock / logout actions. /// -/// This is a static provider — items are populated in `new()` and `refresh()` is a no-op. -pub(crate) struct SystemProvider { +/// Static provider — items are populated in `new()` and `refresh()` is a no-op. +/// +/// type_id is `"power"`. CLI aliases `:sys` and `:system` still resolve here for +/// muscle-memory back-compat (see [`crate::providers::ProviderType::FromStr`] +/// and [`crate::filter::ProviderFilter::parse_query`]). +pub(crate) struct PowerProvider { items: Vec, } -impl SystemProvider { +const TYPE_ID: &str = "power"; + +impl PowerProvider { pub fn new() -> Self { let commands: &[(&str, &str, &str, &str, &str)] = &[ ( @@ -64,14 +71,14 @@ impl SystemProvider { let items = commands .iter() .map(|(action_id, name, description, icon, command)| LaunchItem { - id: format!("sys:{}", action_id), + id: format!("power:{}", action_id), name: name.to_string(), description: Some(description.to_string()), icon: Some(icon.to_string()), - provider: ProviderType::Plugin("sys".into()), + provider: ProviderType::Plugin(TYPE_ID.into()), command: command.to_string(), terminal: false, - tags: vec!["system".into()], + tags: vec!["power".into()], source: ItemSource::Core, }) .collect(); @@ -80,13 +87,13 @@ impl SystemProvider { } } -impl Provider for SystemProvider { +impl Provider for PowerProvider { fn name(&self) -> &str { - "System" + "Power" } fn provider_type(&self) -> ProviderType { - ProviderType::Plugin("sys".into()) + ProviderType::Plugin(TYPE_ID.into()) } fn refresh(&mut self) { @@ -96,6 +103,22 @@ impl Provider for SystemProvider { fn items(&self) -> &[LaunchItem] { &self.items } + + fn prefix(&self) -> Option<&str> { + Some(":power") + } + + fn icon(&self) -> &str { + "system-shutdown" + } + + fn tab_label(&self) -> Option<&str> { + Some("Power") + } + + fn search_noun(&self) -> Option<&str> { + Some("power actions") + } } #[cfg(test)] @@ -104,13 +127,13 @@ mod tests { #[test] fn has_seven_actions() { - let provider = SystemProvider::new(); + let provider = PowerProvider::new(); assert_eq!(provider.items().len(), 7); } #[test] fn contains_expected_action_names() { - let provider = SystemProvider::new(); + let provider = PowerProvider::new(); let names: Vec<&str> = provider.items().iter().map(|i| i.name.as_str()).collect(); assert!(names.contains(&"Shutdown")); assert!(names.contains(&"Reboot")); @@ -119,14 +142,14 @@ mod tests { } #[test] - fn provider_type_is_sys_plugin() { - let provider = SystemProvider::new(); - assert_eq!(provider.provider_type(), ProviderType::Plugin("sys".into())); + fn provider_type_is_power_plugin() { + let provider = PowerProvider::new(); + assert_eq!(provider.provider_type(), ProviderType::Plugin("power".into())); } #[test] fn shutdown_command_is_correct() { - let provider = SystemProvider::new(); + let provider = PowerProvider::new(); let shutdown = provider .items() .iter() @@ -136,14 +159,28 @@ mod tests { } #[test] - fn all_items_have_system_tag() { - let provider = SystemProvider::new(); + fn all_items_have_power_tag() { + let provider = PowerProvider::new(); for item in provider.items() { assert!( - item.tags.contains(&"system".to_string()), - "item '{}' is missing 'system' tag", + item.tags.contains(&"power".to_string()), + "item '{}' is missing 'power' tag", item.name ); } } + + #[test] + fn all_item_ids_use_power_prefix() { + // Frecency / IPC compatibility check: every item id must start with + // the canonical "power:" prefix after the v2 rename. + let provider = PowerProvider::new(); + for item in provider.items() { + assert!( + item.id.starts_with("power:"), + "item id '{}' should start with 'power:'", + item.id + ); + } + } } diff --git a/crates/owlry/src/theme.rs b/crates/owlry/src/theme.rs index b3f4db6..e516e69 100644 --- a/crates/owlry/src/theme.rs +++ b/crates/owlry/src/theme.rs @@ -71,8 +71,10 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String { if let Some(ref badge_ssh) = config.colors.badge_ssh { css.push_str(&format!(" --owlry-badge-ssh: {};\n", badge_ssh)); } - if let Some(ref badge_sys) = config.colors.badge_sys { - css.push_str(&format!(" --owlry-badge-sys: {};\n", badge_sys)); + if let Some(ref badge_power) = config.colors.badge_power { + // Emit both for transition: pre-v2 stylesheets reference --owlry-badge-sys. + css.push_str(&format!(" --owlry-badge-power: {};\n", badge_power)); + css.push_str(&format!(" --owlry-badge-sys: {};\n", badge_power)); } if let Some(ref badge_uuctl) = config.colors.badge_uuctl { css.push_str(&format!(" --owlry-badge-uuctl: {};\n", badge_uuctl)); diff --git a/crates/owlry/src/ui/main_window.rs b/crates/owlry/src/ui/main_window.rs index 291edce..109f40d 100644 --- a/crates/owlry/src/ui/main_window.rs +++ b/crates/owlry/src/ui/main_window.rs @@ -396,8 +396,8 @@ impl MainWindow { if config.converter { parts.push("> conv".to_string()); } - if config.system { - parts.push(":sys".to_string()); + if config.power { + parts.push(":power".to_string()); } parts.join(" ") @@ -595,7 +595,7 @@ impl MainWindow { "pomodoro" => "pomodoro", "scripts" => "scripts", "ssh" => "SSH hosts", - "system" => "system", + "power" | "system" => "power actions", "uuctl" => "uuctl units", "weather" => "weather", "websearch" => "web", diff --git a/crates/owlry/src/ui/provider_meta.rs b/crates/owlry/src/ui/provider_meta.rs index 2f4c517..12cb161 100644 --- a/crates/owlry/src/ui/provider_meta.rs +++ b/crates/owlry/src/ui/provider_meta.rs @@ -86,10 +86,10 @@ fn hardcoded(provider: &ProviderType) -> ProviderMeta { css_class: "owlry-filter-ssh".to_string(), search_noun: "SSH hosts".to_string(), }, - "system" => ProviderMeta { - tab_label: "System".to_string(), - css_class: "owlry-filter-sys".to_string(), - search_noun: "system".to_string(), + "power" | "system" => ProviderMeta { + tab_label: "Power".to_string(), + css_class: "owlry-filter-power".to_string(), + search_noun: "power actions".to_string(), }, "uuctl" => ProviderMeta { tab_label: "uuctl".to_string(), From 1ba0a97e6d0e51b6aba581c0d6da557883299932 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 02:23:31 +0200 Subject: [PATCH 09/23] docs(example): update config.example.toml for sys->power rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to d1c3270 — the prior commit missed the example config because the Edit calls landed before a Read. --- data/config.example.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/data/config.example.toml b/data/config.example.toml index 5101a94..3df833d 100644 --- a/data/config.example.toml +++ b/data/config.example.toml @@ -61,7 +61,7 @@ max_results = 100 # Header tabs — provider tabs shown in the UI bar (Ctrl+1..9 to toggle) # Core values: app, cmd, dmenu -# Plugin values: uuctl, calc, clip, emoji, file, script, ssh, sys, web, bm +# Plugin values: uuctl, calc, clip, emoji, file, ssh, power, web, bm # Any installed plugin's type_id is valid (e.g. "weather", "media") tabs = ["app", "cmd", "uuctl"] @@ -101,7 +101,7 @@ border_radius = 12 # badge_file = "#73daca" # badge_script = "#ff9e64" # badge_ssh = "#2ac3de" -# badge_sys = "#f7768e" +# badge_power = "#f7768e" # alias: badge_sys # badge_uuctl = "#9ece6a" # badge_web = "#7aa2f7" # badge_media = "#bb9af7" @@ -153,7 +153,7 @@ commands = true # Executables from $PATH # Built-in providers (compiled into owlry-core) calculator = true # Math expressions (= or calc trigger) converter = true # Unit/currency conversion (> trigger) -system = true # System actions: shutdown, reboot, lock, etc. +power = true # Power actions: shutdown, reboot, lock, etc. (alias: system) # Frecency — boost frequently/recently used items # Data stored in: ~/.local/share/owlry/frecency.json From 27e2683917d2106820c684eda68f275b43616e63 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 02:26:23 +0200 Subject: [PATCH 10/23] feat(cli): subcommand structure with doctor/providers/config/dmenu/migrate-config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subcommand surface per docs/RESTRUCTURE-V2.md section 2: owlry UI, auto mode (default) owlry -m | --profile UI variants (unchanged) owlry -d Daemon (alias for 'daemon' subcommand) owlry daemon Daemon owlry dmenu [-p PROMPT] dmenu mode (canonical entry; -m dmenu still works) owlry doctor Diagnostics: config + socket + provider list owlry providers [] List providers (or show details for one) owlry config validate Parse config; report errors owlry config show Print resolved effective config as TOML owlry migrate-config Stub; lands with Phase 3 (Lua config) New module crates/owlry/src/commands.rs holds the dispatchers. Each returns ! (calls process::exit) so main.rs is a thin router. doctor and providers connect to the daemon via the local socket and report what's registered. When the daemon is unreachable, doctor prints a hint; providers exits 1. config validate / show use the existing Config loader — no behavioral change to config parsing. Tests added (clap-level): 9 new tests in cli::tests covering each subcommand and the -d-vs-subcommand precedence. 247 total (up from 239) with --features full. Task #9 complete. --- crates/owlry/src/cli.rs | 166 ++++++++++++++++++++++------ crates/owlry/src/commands.rs | 206 +++++++++++++++++++++++++++++++++++ crates/owlry/src/lib.rs | 1 + crates/owlry/src/main.rs | 72 ++++++------ 4 files changed, 375 insertions(+), 70 deletions(-) create mode 100644 crates/owlry/src/commands.rs diff --git a/crates/owlry/src/cli.rs b/crates/owlry/src/cli.rs index 8757d77..307ef2e 100644 --- a/crates/owlry/src/cli.rs +++ b/crates/owlry/src/cli.rs @@ -1,6 +1,6 @@ -//! Command-line interface for owlry launcher +//! Command-line interface for owlry launcher. -use clap::Parser; +use clap::{Parser, Subcommand}; use crate::providers::ProviderType; @@ -16,27 +16,17 @@ EXAMPLES: owlry Launch UI, auto mode owlry -m auto Launch UI, auto mode (explicit) owlry -m app Applications only - owlry -m cmd PATH commands only - owlry -m dmenu dmenu-compatible mode (reads from stdin) owlry --profile dev Use a named profile from config - owlry -d Run the daemon -DMENU MODE: - Pipe input to owlry for interactive selection: + owlry daemon Run the daemon (alias: owlry -d) + owlry dmenu [-p ] dmenu-compatible mode (reads from stdin) + owlry doctor Diagnostics: providers, config, socket + owlry providers [] List providers (or show one) + owlry config validate Check config for errors + owlry config show Print resolved effective config + owlry migrate-config TOML → init.lua (Phase 3+; stub for now) - echo -e \"Option A\\nOption B\" | owlry -m dmenu - ls | owlry -m dmenu -p \"checkout:\" - git branch | owlry -m dmenu --prompt \"checkout:\" - -PROFILES: - Define profiles in ~/.config/owlry/config.toml: - - [profiles.dev] - modes = [\"app\", \"cmd\", \"ssh\"] - - Then launch with: owlry --profile dev - -SEARCH PREFIXES: +SEARCH PREFIXES (inside the UI): :app firefox Search applications :cmd git Search PATH commands :calc / = Calculator (e.g. = 2+2) @@ -46,11 +36,11 @@ SEARCH PREFIXES: For configuration, see ~/.config/owlry/config.toml" )] pub struct CliArgs { - /// Run the daemon (alias for `owlry daemon`, when subcommands land) + /// Run the daemon (alias for `owlry daemon`). #[arg(long, short = 'd', conflicts_with_all = ["mode", "profile", "prompt"])] pub daemon: bool, - /// Start in single-provider mode + /// Start the UI in a single-provider mode. /// /// Core modes: app, cmd, dmenu, auto /// Built-in modes: calc, conv, power @@ -58,19 +48,55 @@ pub struct CliArgs { #[arg(long, short = 'm', value_parser = parse_provider, value_name = "MODE")] pub mode: Option, - /// Use a named profile from config (defines which modes to enable) - /// - /// Profiles are defined in config.toml under [profiles.]. - /// Example: --profile dev (loads modes from [profiles.dev]) + /// Use a named profile from config (defines which modes to enable). #[arg(long, value_name = "NAME")] pub profile: Option, - /// Custom prompt text for the search input - /// - /// Useful in dmenu mode to indicate what the user is selecting. - /// Example: -p "Select file:" or --prompt "Select file:" + /// Custom prompt text for the search input (mainly useful in dmenu mode). #[arg(long, short = 'p', value_name = "TEXT")] pub prompt: Option, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum Command { + /// Run the IPC daemon. Same as `owlry -d`. + Daemon, + + /// dmenu-compatible mode: read stdin, print the selection to stdout. + Dmenu { + /// Prompt text shown in the search input. + #[arg(long, short = 'p', value_name = "TEXT")] + prompt: Option, + }, + + /// Diagnostics: providers, daemon socket, config status. + Doctor, + + /// List providers (or show details for one by id). + Providers { + /// Provider id (e.g. `app`, `uuctl`, `power`). Optional. + id: Option, + }, + + /// Config tools. + Config { + #[command(subcommand)] + action: ConfigAction, + }, + + /// Migrate TOML config to init.lua. Stub in 2.0; lands with Phase 3 (Lua config). + MigrateConfig, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum ConfigAction { + /// Parse config.toml and report any errors. + Validate, + /// Print the resolved effective config (defaults merged with user file). + Show, } fn parse_provider(s: &str) -> Result { @@ -95,6 +121,7 @@ mod tests { assert!(args.mode.is_none()); assert!(args.profile.is_none()); assert!(args.prompt.is_none()); + assert!(args.command.is_none()); } #[test] @@ -111,13 +138,21 @@ mod tests { #[test] fn daemon_flag_conflicts_with_ui_flags() { - // -d must reject UI-only flags so a single invocation can't try to - // both run the daemon and launch a UI in single-mode. assert!(CliArgs::try_parse_from(["owlry", "-d", "-m", "app"]).is_err()); assert!(CliArgs::try_parse_from(["owlry", "-d", "--profile", "dev"]).is_err()); assert!(CliArgs::try_parse_from(["owlry", "-d", "-p", "prompt"]).is_err()); } + #[test] + fn daemon_flag_with_subcommand_resolves_to_daemon_in_dispatcher() { + // clap allows both; main.rs checks args.daemon first and dispatches to + // run_daemon, ignoring the subcommand. Encoded here so the precedence + // can't drift silently. + let args = CliArgs::try_parse_from(["owlry", "-d", "doctor"]).unwrap(); + assert!(args.daemon); + assert!(args.command.is_some()); + } + #[test] fn mode_flag_parses_known_providers() { let args = CliArgs::try_parse_from(["owlry", "-m", "app"]).unwrap(); @@ -129,10 +164,14 @@ mod tests { let args = CliArgs::try_parse_from(["owlry", "-m", "dmenu"]).unwrap(); assert_eq!(args.mode, Some(ProviderType::Dmenu)); - // Unknown modes become Plugin(s) so user-defined providers (Phase 3+) - // and built-in provider IDs (uuctl, bookmarks, etc.) work uniformly. let args = CliArgs::try_parse_from(["owlry", "-m", "uuctl"]).unwrap(); assert_eq!(args.mode, Some(ProviderType::Plugin("uuctl".into()))); + + // Power aliases all resolve to Plugin("power") per D13. + let args = CliArgs::try_parse_from(["owlry", "-m", "power"]).unwrap(); + assert_eq!(args.mode, Some(ProviderType::Plugin("power".into()))); + let args = CliArgs::try_parse_from(["owlry", "-m", "sys"]).unwrap(); + assert_eq!(args.mode, Some(ProviderType::Plugin("power".into()))); } #[test] @@ -141,4 +180,63 @@ mod tests { assert!(CliArgs::try_parse_from(["owlry", "plugin", "list"]).is_err()); assert!(CliArgs::try_parse_from(["owlry", "plugin", "install", "x"]).is_err()); } + + #[test] + fn daemon_subcommand_parses() { + let args = CliArgs::try_parse_from(["owlry", "daemon"]).unwrap(); + assert!(matches!(args.command, Some(Command::Daemon))); + } + + #[test] + fn dmenu_subcommand_with_prompt_parses() { + let args = CliArgs::try_parse_from(["owlry", "dmenu", "-p", "Pick:"]).unwrap(); + match args.command { + Some(Command::Dmenu { prompt }) => assert_eq!(prompt.as_deref(), Some("Pick:")), + other => panic!("expected Dmenu, got {other:?}"), + } + } + + #[test] + fn doctor_subcommand_parses() { + let args = CliArgs::try_parse_from(["owlry", "doctor"]).unwrap(); + assert!(matches!(args.command, Some(Command::Doctor))); + } + + #[test] + fn providers_subcommand_with_optional_id() { + let args = CliArgs::try_parse_from(["owlry", "providers"]).unwrap(); + match args.command { + Some(Command::Providers { id }) => assert!(id.is_none()), + other => panic!("expected Providers, got {other:?}"), + } + let args = CliArgs::try_parse_from(["owlry", "providers", "uuctl"]).unwrap(); + match args.command { + Some(Command::Providers { id }) => assert_eq!(id.as_deref(), Some("uuctl")), + other => panic!("expected Providers, got {other:?}"), + } + } + + #[test] + fn config_validate_subcommand_parses() { + let args = CliArgs::try_parse_from(["owlry", "config", "validate"]).unwrap(); + assert!(matches!( + args.command, + Some(Command::Config { action: ConfigAction::Validate }) + )); + } + + #[test] + fn config_show_subcommand_parses() { + let args = CliArgs::try_parse_from(["owlry", "config", "show"]).unwrap(); + assert!(matches!( + args.command, + Some(Command::Config { action: ConfigAction::Show }) + )); + } + + #[test] + fn migrate_config_subcommand_parses() { + let args = CliArgs::try_parse_from(["owlry", "migrate-config"]).unwrap(); + assert!(matches!(args.command, Some(Command::MigrateConfig))); + } } diff --git a/crates/owlry/src/commands.rs b/crates/owlry/src/commands.rs new file mode 100644 index 0000000..1d0a8af --- /dev/null +++ b/crates/owlry/src/commands.rs @@ -0,0 +1,206 @@ +//! Implementations of CLI subcommands that don't launch the UI. +//! +//! Each function exits the process when done — they're invoked from `main.rs` +//! after subcommand dispatch and never return. + +use std::io::{self, Write}; + +use crate::cli::ConfigAction; +use crate::client::CoreClient; +use crate::config::Config; +use crate::ipc::ProviderDesc; +use crate::{paths, server}; + +/// `owlry daemon` / `owlry -d`: bind the IPC socket and run the daemon loop. +pub fn run_daemon() -> ! { + let sock = paths::socket_path(); + if let Err(e) = paths::ensure_parent_dir(&sock) { + eprintln!("Failed to create socket directory: {e}"); + std::process::exit(1); + } + match server::Server::bind(&sock) { + Ok(s) => match s.run() { + Ok(()) => std::process::exit(0), + Err(e) => { + eprintln!("Server error: {e}"); + std::process::exit(1); + } + }, + Err(e) => { + eprintln!("Failed to start daemon: {e}"); + std::process::exit(1); + } + } +} + +/// `owlry doctor`: print the daemon socket status, loaded provider list, and +/// config status. Useful for confirming an install is wired correctly. +pub fn run_doctor() -> ! { + let stdout = io::stdout(); + let mut out = stdout.lock(); + + let _ = writeln!(out, "owlry doctor"); + let _ = writeln!(out, "============"); + let _ = writeln!(out); + + // Config check. + let _ = writeln!(out, "[config]"); + match Config::load() { + Ok(_) => { + let _ = writeln!(out, " OK"); + } + Err(e) => { + let _ = writeln!(out, " ERROR: {e}"); + } + } + let _ = writeln!(out); + + // Daemon socket. + let sock = paths::socket_path(); + let _ = writeln!(out, "[daemon]"); + let _ = writeln!(out, " socket path: {}", sock.display()); + match CoreClient::connect(&sock) { + Ok(mut client) => { + let _ = writeln!(out, " socket: OK (daemon reachable)"); + match client.providers() { + Ok(list) => { + let _ = writeln!(out); + let _ = writeln!(out, "[providers] {} registered", list.len()); + print_provider_list(&mut out, &list); + } + Err(e) => { + let _ = writeln!(out, " providers query failed: {e}"); + } + } + } + Err(e) => { + let _ = writeln!(out, " socket: UNREACHABLE ({e})"); + let _ = writeln!(out, " hint: start the daemon with `owlry -d` or via systemd"); + } + } + + std::process::exit(0); +} + +/// `owlry providers []`: list providers, or show one in detail. +pub fn run_providers(id: Option) -> ! { + let sock = paths::socket_path(); + let mut client = match CoreClient::connect(&sock) { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to reach daemon socket {}: {}", sock.display(), e); + eprintln!("Hint: start the daemon with `owlry -d`."); + std::process::exit(1); + } + }; + + let list = match client.providers() { + Ok(l) => l, + Err(e) => { + eprintln!("Daemon providers query failed: {e}"); + std::process::exit(1); + } + }; + + let stdout = io::stdout(); + let mut out = stdout.lock(); + + match id { + None => { + let _ = writeln!(out, "{} provider(s) registered", list.len()); + print_provider_list(&mut out, &list); + } + Some(target) => match list.iter().find(|p| p.id == target) { + Some(p) => { + let _ = writeln!(out, "id: {}", p.id); + let _ = writeln!(out, "name: {}", p.name); + let _ = writeln!(out, "icon: {}", p.icon); + let _ = writeln!(out, "position: {}", p.position); + if let Some(prefix) = &p.prefix { + let _ = writeln!(out, "prefix: {prefix}"); + } + if let Some(tab_label) = &p.tab_label { + let _ = writeln!(out, "tab_label: {tab_label}"); + } + if let Some(search_noun) = &p.search_noun { + let _ = writeln!(out, "search_noun: {search_noun}"); + } + } + None => { + eprintln!("No provider with id '{target}'."); + std::process::exit(1); + } + }, + } + + std::process::exit(0); +} + +fn print_provider_list(out: &mut impl Write, list: &[ProviderDesc]) { + for p in list { + let prefix = p.prefix.as_deref().unwrap_or("-"); + let _ = writeln!(out, " {:<12} {:<22} prefix={}", p.id, p.name, prefix); + } +} + +/// `owlry config validate`: parse the config file and report errors. +pub fn run_config_validate() -> ! { + match Config::load() { + Ok(_) => { + println!("config: OK"); + std::process::exit(0); + } + Err(e) => { + eprintln!("config: ERROR — {e}"); + std::process::exit(1); + } + } +} + +/// `owlry config show`: serialize the effective config to stdout as TOML. +pub fn run_config_show() -> ! { + let cfg = Config::load_or_default(); + match toml::to_string_pretty(&cfg) { + Ok(s) => { + print!("{s}"); + std::process::exit(0); + } + Err(e) => { + eprintln!("config: failed to serialize ({e})"); + std::process::exit(1); + } + } +} + +pub fn run_config(action: ConfigAction) -> ! { + match action { + ConfigAction::Validate => run_config_validate(), + ConfigAction::Show => run_config_show(), + } +} + +/// `owlry migrate-config`: TOML → init.lua. Lands in Phase 3 (Lua config). +pub fn run_migrate_config() -> ! { + eprintln!( + "migrate-config: not yet implemented.\n\ + The Lua config layer lands in 2.1+ (or 3.0). TOML config remains the\n\ + active format in 2.0. See docs/RESTRUCTURE-V2.md (phase 3) for status." + ); + std::process::exit(2); +} + +/// `owlry dmenu`: delegate to the UI's dmenu pipeline. +/// +/// The UI reads stdin itself when launched in dmenu mode — we just construct +/// the equivalent of `owlry -m dmenu [-p prompt]` and hand off. +pub fn run_dmenu(prompt: Option) -> ! { + let args = crate::cli::CliArgs { + daemon: false, + mode: Some(crate::providers::ProviderType::Dmenu), + profile: None, + prompt, + command: None, + }; + let app = crate::app::OwlryApp::new(args); + std::process::exit(app.run()); +} diff --git a/crates/owlry/src/lib.rs b/crates/owlry/src/lib.rs index 7b26b4c..81b7171 100644 --- a/crates/owlry/src/lib.rs +++ b/crates/owlry/src/lib.rs @@ -8,6 +8,7 @@ pub mod app; pub mod backend; pub mod cli; pub mod client; +pub mod commands; pub mod config; pub mod data; pub mod filter; diff --git a/crates/owlry/src/main.rs b/crates/owlry/src/main.rs index 96c1c11..e5217fb 100644 --- a/crates/owlry/src/main.rs +++ b/crates/owlry/src/main.rs @@ -2,8 +2,8 @@ use log::{info, warn}; use std::os::unix::io::AsRawFd; use owlry::app::OwlryApp; -use owlry::cli::CliArgs; -use owlry::{client, paths, server}; +use owlry::cli::{CliArgs, Command}; +use owlry::{client, commands, paths}; #[cfg(feature = "dev-logging")] use log::debug; @@ -42,47 +42,34 @@ fn try_acquire_lock() -> Option { fn main() { let args = CliArgs::parse_args(); - // -d / --daemon: run the daemon in-process and exit when it stops. + // Subcommand dispatch. Each commands::* function calls std::process::exit + // and never returns. Daemon mode also installs env_logger; the other + // subcommands stay quiet by default. if args.daemon { - let default_level = if cfg!(feature = "dev-logging") { - "debug" - } else { - "info" - }; - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_level)) - .format_timestamp_millis() - .init(); + init_logger(); + commands::run_daemon(); + } - let sock = paths::socket_path(); - if let Err(e) = paths::ensure_parent_dir(&sock) { - eprintln!("Failed to create socket directory: {e}"); - std::process::exit(1); + match args.command.clone() { + Some(Command::Daemon) => { + init_logger(); + commands::run_daemon(); } - match server::Server::bind(&sock) { - Ok(server) => { - if let Err(e) = server.run() { - eprintln!("Server error: {e}"); - std::process::exit(1); - } - std::process::exit(0); - } - Err(e) => { - eprintln!("Failed to start daemon: {e}"); - std::process::exit(1); - } + Some(Command::Doctor) => commands::run_doctor(), + Some(Command::Providers { id }) => commands::run_providers(id), + Some(Command::Config { action }) => commands::run_config(action), + Some(Command::MigrateConfig) => commands::run_migrate_config(), + Some(Command::Dmenu { prompt }) => { + init_logger(); + commands::run_dmenu(prompt); + } + None => { + // Fall through to UI launch below. } } // Default: launch the UI. - let default_level = if cfg!(feature = "dev-logging") { - "debug" - } else { - "info" - }; - - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_level)) - .format_timestamp_millis() - .init(); + init_logger(); #[cfg(feature = "dev-logging")] { @@ -129,3 +116,16 @@ fn main() { let app = OwlryApp::new(args); std::process::exit(app.run()); } + +fn init_logger() { + let default_level = if cfg!(feature = "dev-logging") { + "debug" + } else { + "info" + }; + let _ = env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or(default_level), + ) + .format_timestamp_millis() + .try_init(); +} From e9f310d202c7f125d34b27e2103fb809f8b94117 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 02:27:16 +0200 Subject: [PATCH 11/23] test(auto-mode): integration test guarding the no-flag default behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per D7 and task #10 in the v2 plan. Five tests pinning down the `ProviderFilter` invariants that drive the default 'owlry' launch: - auto_mode_filter_accepts_every_provider_type: no CLI mode/profile yields accept_all=true; every provider type (including ones not in general.tabs) is searchable. - auto_mode_filter_with_empty_tabs_still_accepts_everything: even an empty tabs list doesn't silently drop providers from query routing. - auto_mode_changes_to_filtered_when_cli_mode_is_set: counterpoint — -m flips accept_all off and excludes others. - auto_mode_prefix_overrides_accept_all_for_routing: :uuctl prefix inside the UI narrows even when accept_all is true; clearing the prefix restores reach. - dash_m_auto_explicit_alias_is_equivalent_to_no_flag: 'auto' parses to Plugin('auto'); the value lives under user control and won't be silently remapped. 252 total tests with --features full. Task #10 complete. --- crates/owlry/tests/auto_mode.rs | 91 +++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 crates/owlry/tests/auto_mode.rs diff --git a/crates/owlry/tests/auto_mode.rs b/crates/owlry/tests/auto_mode.rs new file mode 100644 index 0000000..837bda8 --- /dev/null +++ b/crates/owlry/tests/auto_mode.rs @@ -0,0 +1,91 @@ +//! Auto-mode integration test (Phase 1 task #10, D7). +//! +//! Pins down the invariant that `owlry` invoked with no `-m`/`--profile`/CLI +//! restriction surfaces results from every enabled provider, with tabs +//! reflecting `general.tabs`. This is the existing default behavior that +//! refactors in subsequent phases must not silently regress. + +use owlry::config::ProvidersConfig; +use owlry::filter::ProviderFilter; +use owlry::providers::ProviderType; + +/// Build a filter the same way `Server::bind` does when the user runs `owlry` +/// with no CLI mode and no profile: `cli_mode=None`, `cli_providers=None`. +fn auto_mode_filter(tabs: &[&str]) -> ProviderFilter { + let cfg = ProvidersConfig::default(); + let tabs: Vec = tabs.iter().map(|s| s.to_string()).collect(); + ProviderFilter::new(None, None, &cfg, &tabs) +} + +#[test] +fn auto_mode_filter_accepts_every_provider_type() { + // Even providers the user didn't list in `general.tabs` must remain + // searchable — tabs only drive UI display, not query routing. + let filter = auto_mode_filter(&["app", "cmd", "power"]); + + assert!(filter.is_accept_all(), "auto mode must set accept_all=true"); + + assert!(filter.is_active(ProviderType::Application)); + assert!(filter.is_active(ProviderType::Command)); + assert!(filter.is_active(ProviderType::Plugin("power".into()))); + // Providers NOT in tabs are still active in auto mode. + assert!(filter.is_active(ProviderType::Plugin("uuctl".into()))); + assert!(filter.is_active(ProviderType::Plugin("bookmarks".into()))); + assert!(filter.is_active(ProviderType::Plugin("anything-else".into()))); +} + +#[test] +fn auto_mode_filter_with_empty_tabs_still_accepts_everything() { + // Even an empty `general.tabs` list (degenerate config) must not silently + // drop providers from query routing — accept_all stays true. + let filter = auto_mode_filter(&[]); + assert!(filter.is_accept_all()); + assert!(filter.is_active(ProviderType::Application)); + assert!(filter.is_active(ProviderType::Plugin("uuctl".into()))); +} + +#[test] +fn auto_mode_changes_to_filtered_when_cli_mode_is_set() { + // Counterpoint: passing `-m app` flips accept_all off so the user sees only + // applications. Encodes the contract from the other direction. + let cfg = ProvidersConfig::default(); + let tabs: Vec = vec!["app".into(), "cmd".into()]; + let filter = ProviderFilter::new(Some(ProviderType::Application), None, &cfg, &tabs); + + assert!(!filter.is_accept_all(), "single-mode must clear accept_all"); + assert!(filter.is_active(ProviderType::Application)); + assert!(!filter.is_active(ProviderType::Command)); + assert!(!filter.is_active(ProviderType::Plugin("uuctl".into()))); +} + +#[test] +fn auto_mode_prefix_overrides_accept_all_for_routing() { + // Inside the UI, typing `:uuctl foo` should narrow results to that + // provider even when the filter is otherwise accept_all. The prefix is + // a per-keystroke override; the filter's enabled set is unchanged. + let mut filter = auto_mode_filter(&["app", "cmd"]); + assert!(filter.is_accept_all()); + + filter.set_prefix(Some(ProviderType::Plugin("uuctl".into()))); + assert!(filter.is_active(ProviderType::Plugin("uuctl".into()))); + assert!(!filter.is_active(ProviderType::Application)); + assert!(!filter.is_active(ProviderType::Command)); + + // Clearing the prefix restores the accept_all reach. + filter.set_prefix(None); + assert!(filter.is_active(ProviderType::Application)); + assert!(filter.is_active(ProviderType::Plugin("uuctl".into()))); +} + +#[test] +fn dash_m_auto_explicit_alias_is_equivalent_to_no_flag() { + // `owlry -m auto` is the documented explicit form of the no-flag default. + // The parser turns `auto` into Plugin("auto"); the main launcher treats + // any unknown-to-us mode like the default. Here we just confirm the + // FromStr resolution stays under the user's control — implementation of + // the actual treatment lives in the UI, but the parsing must not silently + // map `auto` to something else. + use std::str::FromStr; + let parsed = ProviderType::from_str("auto").unwrap(); + assert_eq!(parsed, ProviderType::Plugin("auto".into())); +} From c48efaa7a5b03d79118953c205dbf29721c7f252 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 02:30:10 +0200 Subject: [PATCH 12/23] style(v2): apply cargo fmt across the workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routine formatting pass after the feature-gate / config / CLI / power work landed. Import ordering, line wrapping, and trailing-comma cleanup only — no behavior changes. `cargo fmt --all --check` is now clean. Part of Phase 1 task #11 (final build + smoke). --- crates/owlry/src/app.rs | 10 +- crates/owlry/src/backend.rs | 2 +- crates/owlry/src/cli.rs | 8 +- crates/owlry/src/client.rs | 6 +- crates/owlry/src/commands.rs | 5 +- crates/owlry/src/config/mod.rs | 6 +- crates/owlry/src/data/frecency.rs | 30 +++-- crates/owlry/src/filter.rs | 12 +- crates/owlry/src/main.rs | 14 +-- crates/owlry/src/providers/application.rs | 5 +- crates/owlry/src/providers/calculator.rs | 13 +-- crates/owlry/src/providers/converter/units.rs | 24 +++- crates/owlry/src/providers/dmenu.rs | 2 +- crates/owlry/src/providers/emoji.rs | 5 +- crates/owlry/src/providers/mod.rs | 56 ++++++++-- crates/owlry/src/providers/power.rs | 5 +- crates/owlry/src/server.rs | 105 ++++++++++++------ crates/owlry/src/ui/main_window.rs | 79 ++++++------- crates/owlry/src/ui/result_row.rs | 6 +- 19 files changed, 240 insertions(+), 153 deletions(-) diff --git a/crates/owlry/src/app.rs b/crates/owlry/src/app.rs index 75148a4..b6e6958 100644 --- a/crates/owlry/src/app.rs +++ b/crates/owlry/src/app.rs @@ -1,18 +1,18 @@ use crate::backend::SearchBackend; use crate::cli::CliArgs; use crate::client::CoreClient; +use crate::config::Config; +use crate::data::FrecencyStore; +use crate::filter::ProviderFilter; +use crate::paths; use crate::providers::DmenuProvider; +use crate::providers::{Provider, ProviderManager, ProviderType}; use crate::theme; use crate::ui::MainWindow; use gtk4::prelude::*; use gtk4::{Application, CssProvider, gio}; use gtk4_layer_shell::{Edge, Layer, LayerShell}; use log::{debug, info, warn}; -use crate::config::Config; -use crate::data::FrecencyStore; -use crate::filter::ProviderFilter; -use crate::paths; -use crate::providers::{Provider, ProviderManager, ProviderType}; use std::cell::RefCell; use std::rc::Rc; diff --git a/crates/owlry/src/backend.rs b/crates/owlry/src/backend.rs index 5c2f9fa..042a16f 100644 --- a/crates/owlry/src/backend.rs +++ b/crates/owlry/src/backend.rs @@ -4,12 +4,12 @@ //! In dmenu mode, the UI uses a local ProviderManager directly (no daemon). use crate::client::CoreClient; -use log::warn; use crate::config::Config; use crate::data::FrecencyStore; use crate::filter::ProviderFilter; use crate::ipc::{ProviderDesc, ResultItem}; use crate::providers::{ItemSource, LaunchItem, ProviderManager, ProviderType}; +use log::warn; use std::sync::{Arc, Mutex}; /// Parameters needed to run a search query on a background thread. diff --git a/crates/owlry/src/cli.rs b/crates/owlry/src/cli.rs index 307ef2e..26417d2 100644 --- a/crates/owlry/src/cli.rs +++ b/crates/owlry/src/cli.rs @@ -221,7 +221,9 @@ mod tests { let args = CliArgs::try_parse_from(["owlry", "config", "validate"]).unwrap(); assert!(matches!( args.command, - Some(Command::Config { action: ConfigAction::Validate }) + Some(Command::Config { + action: ConfigAction::Validate + }) )); } @@ -230,7 +232,9 @@ mod tests { let args = CliArgs::try_parse_from(["owlry", "config", "show"]).unwrap(); assert!(matches!( args.command, - Some(Command::Config { action: ConfigAction::Show }) + Some(Command::Config { + action: ConfigAction::Show + }) )); } diff --git a/crates/owlry/src/client.rs b/crates/owlry/src/client.rs index de3ebb1..bb97ef8 100644 --- a/crates/owlry/src/client.rs +++ b/crates/owlry/src/client.rs @@ -79,9 +79,7 @@ impl CoreClient { let status = std::process::Command::new("systemctl") .args(["--user", "start", "owlryd"]) .status() - .map_err(|e| { - io::Error::other(format!("failed to start owlryd via systemd: {e}")) - })?; + .map_err(|e| io::Error::other(format!("failed to start owlryd via systemd: {e}")))?; if !status.success() { return Err(io::Error::other(format!( @@ -231,7 +229,7 @@ impl CoreClient { return Err(io::Error::new( io::ErrorKind::UnexpectedEof, "daemon closed the connection", - )) + )); } }; serde_json::from_str(line.trim()).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) diff --git a/crates/owlry/src/commands.rs b/crates/owlry/src/commands.rs index 1d0a8af..7148610 100644 --- a/crates/owlry/src/commands.rs +++ b/crates/owlry/src/commands.rs @@ -75,7 +75,10 @@ pub fn run_doctor() -> ! { } Err(e) => { let _ = writeln!(out, " socket: UNREACHABLE ({e})"); - let _ = writeln!(out, " hint: start the daemon with `owlry -d` or via systemd"); + let _ = writeln!( + out, + " hint: start the daemon with `owlry -d` or via systemd" + ); } } diff --git a/crates/owlry/src/config/mod.rs b/crates/owlry/src/config/mod.rs index ecde8c6..bcc50b1 100644 --- a/crates/owlry/src/config/mod.rs +++ b/crates/owlry/src/config/mod.rs @@ -262,7 +262,6 @@ pub struct PluginsConfig { /// Defaults to the official owlry plugin registry if not specified. #[serde(default)] pub registry_url: Option, - } /// Sandbox settings for plugin security @@ -646,6 +645,9 @@ system = false badge_sys = "#ff8800" "##; let cfg: Config = toml::from_str(toml).expect("must parse"); - assert_eq!(cfg.appearance.colors.badge_power.as_deref(), Some("#ff8800")); + assert_eq!( + cfg.appearance.colors.badge_power.as_deref(), + Some("#ff8800") + ); } } diff --git a/crates/owlry/src/data/frecency.rs b/crates/owlry/src/data/frecency.rs index 584a3e1..181f209 100644 --- a/crates/owlry/src/data/frecency.rs +++ b/crates/owlry/src/data/frecency.rs @@ -67,9 +67,9 @@ impl FrecencyStore { let cutoff = now - chrono::Duration::days(PRUNE_AGE_DAYS); let before = self.data.entries.len(); - self.data.entries.retain(|_, e| { - e.last_launch > cutoff || e.launch_count >= MIN_LAUNCHES_TO_KEEP - }); + self.data + .entries + .retain(|_, e| e.last_launch > cutoff || e.launch_count >= MIN_LAUNCHES_TO_KEEP); if self.data.entries.len() > MAX_ENTRIES { // Sort by score descending and keep the top MAX_ENTRIES @@ -78,18 +78,28 @@ impl FrecencyStore { .entries .iter() .map(|(k, e)| { - (k.clone(), Self::calculate_frecency_at(e.launch_count, e.last_launch, now)) + ( + k.clone(), + Self::calculate_frecency_at(e.launch_count, e.last_launch, now), + ) }) .collect(); scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - let keep: std::collections::HashSet = - scored.into_iter().take(MAX_ENTRIES).map(|(k, _)| k).collect(); + let keep: std::collections::HashSet = scored + .into_iter() + .take(MAX_ENTRIES) + .map(|(k, _)| k) + .collect(); self.data.entries.retain(|k, _| keep.contains(k)); } let removed = before - self.data.entries.len(); if removed > 0 { - info!("Frecency: pruned {} stale entries ({} remaining)", removed, self.data.entries.len()); + info!( + "Frecency: pruned {} stale entries ({} remaining)", + removed, + self.data.entries.len() + ); self.dirty = true; } } @@ -185,7 +195,11 @@ impl FrecencyStore { } /// Calculate frecency using a caller-provided timestamp. - fn calculate_frecency_at(launch_count: u32, last_launch: DateTime, now: DateTime) -> f64 { + fn calculate_frecency_at( + launch_count: u32, + last_launch: DateTime, + now: DateTime, + ) -> f64 { let age = now.signed_duration_since(last_launch); let age_days = age.num_hours() as f64 / 24.0; diff --git a/crates/owlry/src/filter.rs b/crates/owlry/src/filter.rs index 90973d8..1c0d7a1 100644 --- a/crates/owlry/src/filter.rs +++ b/crates/owlry/src/filter.rs @@ -321,14 +321,22 @@ impl ProviderFilter { 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 == '_') { + 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 == '_') { + } else if !rest.is_empty() + && rest + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { // Partial prefix (no space yet) return ParsedQuery { prefix: Some(ProviderType::Plugin(rest.to_string())), diff --git a/crates/owlry/src/main.rs b/crates/owlry/src/main.rs index e5217fb..10e1cb0 100644 --- a/crates/owlry/src/main.rs +++ b/crates/owlry/src/main.rs @@ -16,10 +16,7 @@ use log::debug; fn try_acquire_lock() -> Option { use std::os::unix::fs::OpenOptionsExt; - let lock_path = paths::socket_path() - .parent() - .unwrap() - .join("owlry-ui.lock"); + let lock_path = paths::socket_path().parent().unwrap().join("owlry-ui.lock"); if let Some(parent) = lock_path.parent() { let _ = std::fs::create_dir_all(parent); @@ -123,9 +120,8 @@ fn init_logger() { } else { "info" }; - let _ = env_logger::Builder::from_env( - env_logger::Env::default().default_filter_or(default_level), - ) - .format_timestamp_millis() - .try_init(); + let _ = + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_level)) + .format_timestamp_millis() + .try_init(); } diff --git a/crates/owlry/src/providers/application.rs b/crates/owlry/src/providers/application.rs index e00061d..dd9523d 100644 --- a/crates/owlry/src/providers/application.rs +++ b/crates/owlry/src/providers/application.rs @@ -301,7 +301,10 @@ mod tests { #[test] fn test_clean_desktop_exec_collapses_spaces() { - assert_eq!(clean_desktop_exec_field("app --flag arg"), "app --flag arg"); + 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"); } diff --git a/crates/owlry/src/providers/calculator.rs b/crates/owlry/src/providers/calculator.rs index 0f5e973..900b0d6 100644 --- a/crates/owlry/src/providers/calculator.rs +++ b/crates/owlry/src/providers/calculator.rs @@ -31,10 +31,8 @@ impl DynamicProvider for CalculatorProvider { match eval_math(expr) { Ok(result) => { let display = format_result(result); - let copy_cmd = format!( - "printf '%s' '{}' | wl-copy", - display.replace('\'', "'\\''") - ); + let copy_cmd = + format!("printf '%s' '{}' | wl-copy", display.replace('\'', "'\\''")); vec![LaunchItem { id: format!("calc:{}", expr), name: display.clone(), @@ -105,11 +103,8 @@ fn extract_expression(query: &str) -> Option<&str> { fn looks_like_math(s: &str) -> bool { // Must contain at least one digit or a known constant/function name let has_digit = s.chars().any(|c| c.is_ascii_digit()); - let has_operator = s.contains('+') - || s.contains('*') - || s.contains('/') - || s.contains('^') - || s.contains('%'); + let has_operator = + s.contains('+') || s.contains('*') || s.contains('/') || s.contains('^') || s.contains('%'); // Subtraction/negation is ambiguous; only count it as an operator when // there are already digits present to avoid matching bare words with hyphens. let has_minus_operator = has_digit && s.contains('-'); diff --git a/crates/owlry/src/providers/converter/units.rs b/crates/owlry/src/providers/converter/units.rs index 2607b82..fcd6489 100644 --- a/crates/owlry/src/providers/converter/units.rs +++ b/crates/owlry/src/providers/converter/units.rs @@ -167,8 +167,16 @@ fn convert_currency(value: f64, from: &str, to: &str) -> Option Vec { .iter() .filter(|&&sym| sym != from_code) .filter_map(|&sym| { - let to_rate = if sym == "EUR" { 1.0 } else { *rates.rates.get(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)) }) @@ -939,6 +951,10 @@ mod tests { let r = convert_to(&100.0, "km", "mi").unwrap(); // display_value should contain the symbol exactly once let count = r.display_value.matches(&r.target_symbol).count(); - assert_eq!(count, 1, "display_value '{}' should contain '{}' exactly once", r.display_value, r.target_symbol); + assert_eq!( + count, 1, + "display_value '{}' should contain '{}' exactly once", + r.display_value, r.target_symbol + ); } } diff --git a/crates/owlry/src/providers/dmenu.rs b/crates/owlry/src/providers/dmenu.rs index e148520..050faa0 100644 --- a/crates/owlry/src/providers/dmenu.rs +++ b/crates/owlry/src/providers/dmenu.rs @@ -1,5 +1,5 @@ -use log::debug; use crate::providers::{ItemSource, LaunchItem, Provider, ProviderType}; +use log::debug; use std::io::{self, BufRead}; /// Provider for dmenu-style input from stdin diff --git a/crates/owlry/src/providers/emoji.rs b/crates/owlry/src/providers/emoji.rs index ff22749..3ade285 100644 --- a/crates/owlry/src/providers/emoji.rs +++ b/crates/owlry/src/providers/emoji.rs @@ -478,10 +478,7 @@ mod tests { let mut provider = EmojiProvider::new(); provider.refresh(); - let grinning = provider - .items() - .iter() - .find(|i| i.name == "grinning face"); + let grinning = provider.items().iter().find(|i| i.name == "grinning face"); assert!(grinning.is_some()); let item = grinning.unwrap(); diff --git a/crates/owlry/src/providers/mod.rs b/crates/owlry/src/providers/mod.rs index 37381ab..f837cc8 100644 --- a/crates/owlry/src/providers/mod.rs +++ b/crates/owlry/src/providers/mod.rs @@ -1,9 +1,9 @@ // Core providers (compiled in) mod application; -mod command; -pub mod dmenu; pub(crate) mod calculator; +mod command; pub(crate) mod converter; +pub mod dmenu; pub(crate) mod power; // Optional feature-gated providers @@ -533,7 +533,11 @@ impl ProviderManager { } scored_refs.sort_by(|a, b| b.1.cmp(&a.1)); - results.extend(scored_refs.into_iter().map(|(item, score)| (item.clone(), score))); + 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; @@ -605,7 +609,11 @@ impl ProviderManager { } scored_refs.sort_by(|a, b| b.1.cmp(&a.1)); - results.extend(scored_refs.into_iter().map(|(item, score)| (item.clone(), score))); + 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); @@ -706,7 +714,10 @@ impl ProviderManager { display_name: &str, ) -> Option<(String, Vec)> { #[cfg(feature = "dev-logging")] - debug!("[Submenu] Querying provider '{}' with data: {}", plugin_id, data); + debug!( + "[Submenu] Querying provider '{}' with data: {}", + plugin_id, data + ); for provider in &self.providers { let matches = match provider.provider_type() { @@ -980,8 +991,14 @@ mod tests { fn provider_type_from_str_accepts_plural_aliases() { // After the demolition, FromStr accepts both singular and plural aliases. use std::str::FromStr; - assert_eq!(ProviderType::from_str("app").unwrap(), ProviderType::Application); - assert_eq!(ProviderType::from_str("apps").unwrap(), ProviderType::Application); + assert_eq!( + ProviderType::from_str("app").unwrap(), + ProviderType::Application + ); + assert_eq!( + ProviderType::from_str("apps").unwrap(), + ProviderType::Application + ); assert_eq!( ProviderType::from_str("application").unwrap(), ProviderType::Application @@ -990,8 +1007,14 @@ mod tests { ProviderType::from_str("applications").unwrap(), ProviderType::Application ); - assert_eq!(ProviderType::from_str("cmd").unwrap(), ProviderType::Command); - assert_eq!(ProviderType::from_str("cmds").unwrap(), ProviderType::Command); + assert_eq!( + ProviderType::from_str("cmd").unwrap(), + ProviderType::Command + ); + assert_eq!( + ProviderType::from_str("cmds").unwrap(), + ProviderType::Command + ); assert_eq!( ProviderType::from_str("command").unwrap(), ProviderType::Command @@ -1000,7 +1023,10 @@ mod tests { ProviderType::from_str("commands").unwrap(), ProviderType::Command ); - assert_eq!(ProviderType::from_str("dmenu").unwrap(), ProviderType::Dmenu); + assert_eq!( + ProviderType::from_str("dmenu").unwrap(), + ProviderType::Dmenu + ); // Anything unknown becomes Plugin(s) — preserves user-defined provider IDs. assert_eq!( ProviderType::from_str("bookmarks").unwrap(), @@ -1086,7 +1112,10 @@ mod tests { let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); let result = pm.query_submenu_actions("uuctl", "foo.service:true", "foo"); - assert!(result.is_some(), "expected Some when provider matches and returns actions"); + assert!( + result.is_some(), + "expected Some when provider matches and returns actions" + ); let (display, actions) = result.unwrap(); assert_eq!(display, "foo"); assert_eq!(actions.len(), 1); @@ -1102,7 +1131,10 @@ mod tests { action_handled_for: None, }; let pm = ProviderManager::new(vec![Box::new(prov)], Vec::new()); - assert!(pm.query_submenu_actions("does-not-exist", "data", "name").is_none()); + assert!( + pm.query_submenu_actions("does-not-exist", "data", "name") + .is_none() + ); } #[test] diff --git a/crates/owlry/src/providers/power.rs b/crates/owlry/src/providers/power.rs index 9003bf9..244c463 100644 --- a/crates/owlry/src/providers/power.rs +++ b/crates/owlry/src/providers/power.rs @@ -144,7 +144,10 @@ mod tests { #[test] fn provider_type_is_power_plugin() { let provider = PowerProvider::new(); - assert_eq!(provider.provider_type(), ProviderType::Plugin("power".into())); + assert_eq!( + provider.provider_type(), + ProviderType::Plugin("power".into()) + ); } #[test] diff --git a/crates/owlry/src/server.rs b/crates/owlry/src/server.rs index 86df4a4..26c8a3c 100644 --- a/crates/owlry/src/server.rs +++ b/crates/owlry/src/server.rs @@ -4,8 +4,8 @@ use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, RwLock}; -use std::time::Duration; use std::thread; +use std::time::Duration; /// Maximum allowed size for a single IPC request line (1 MiB). const MAX_REQUEST_SIZE: usize = 1_048_576; @@ -130,22 +130,19 @@ impl Server { use signal_hook::consts::SIGHUP; use signal_hook::iterator::Signals; let config = Arc::clone(&self.config); - let mut signals = Signals::new([SIGHUP]) - .map_err(io::Error::other)?; + let mut signals = Signals::new([SIGHUP]).map_err(io::Error::other)?; thread::spawn(move || { for _sig in signals.forever() { match Config::load() { - Ok(new_cfg) => { - match config.write() { - Ok(mut cfg) => { - *cfg = new_cfg; - info!("Config reloaded via SIGHUP"); - } - Err(_) => { - warn!("SIGHUP: config lock poisoned; reload skipped"); - } + Ok(new_cfg) => match config.write() { + Ok(mut cfg) => { + *cfg = new_cfg; + info!("Config reloaded via SIGHUP"); } - } + Err(_) => { + warn!("SIGHUP: config lock poisoned; reload skipped"); + } + }, Err(e) => { warn!("SIGHUP: failed to reload config: {}", e); } @@ -161,8 +158,7 @@ impl Server { use signal_hook::iterator::Signals; let frecency = Arc::clone(&self.frecency); let socket_path = self.socket_path.clone(); - let mut signals = Signals::new([SIGTERM, SIGINT]) - .map_err(io::Error::other)?; + let mut signals = Signals::new([SIGTERM, SIGINT]).map_err(io::Error::other)?; thread::spawn(move || { // Block until we receive SIGTERM or SIGINT, then save and exit. let _ = signals.forever().next(); @@ -186,16 +182,18 @@ impl Server { // Periodic frecency auto-save every 5 minutes. { let frecency = Arc::clone(&self.frecency); - thread::spawn(move || loop { - thread::sleep(Duration::from_secs(300)); - match frecency.write() { - Ok(mut f) => { - if let Err(e) = f.save() { - warn!("Periodic frecency save failed: {}", e); + thread::spawn(move || { + loop { + thread::sleep(Duration::from_secs(300)); + match frecency.write() { + Ok(mut f) => { + if let Err(e) = f.save() { + warn!("Periodic frecency save failed: {}", e); + } + } + Err(_) => { + warn!("Periodic frecency save: lock poisoned; skipping"); } - } - Err(_) => { - warn!("Periodic frecency save: lock poisoned; skipping"); } } }); @@ -218,9 +216,15 @@ impl Server { }); } None => { - warn!("Connection limit reached ({} max); rejecting client", MAX_CONNECTIONS); + warn!( + "Connection limit reached ({} max); rejecting client", + MAX_CONNECTIONS + ); let resp = Response::Error { - message: format!("server busy: max {} concurrent connections", MAX_CONNECTIONS), + message: format!( + "server busy: max {} concurrent connections", + MAX_CONNECTIONS + ), }; let _ = write_response(&mut stream, &resp); } @@ -315,18 +319,30 @@ impl Server { let (max, weight) = { let cfg = match config.read() { Ok(g) => g, - Err(_) => return Response::Error { message: "internal error: config lock poisoned".into() }, + Err(_) => { + return Response::Error { + message: "internal error: config lock poisoned".into(), + }; + } }; (cfg.general.max_results, cfg.providers.frecency_weight) }; let pm_guard = match pm.read() { Ok(g) => g, - Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() }, + Err(_) => { + return Response::Error { + message: "internal error: provider lock poisoned".into(), + }; + } }; let frecency_guard = match frecency.read() { Ok(g) => g, - Err(_) => return Response::Error { message: "internal error: frecency lock poisoned".into() }, + Err(_) => { + return Response::Error { + message: "internal error: frecency lock poisoned".into(), + }; + } }; let results = pm_guard.search_with_frecency( text, @@ -351,7 +367,11 @@ impl Server { } => { let mut frecency_guard = match frecency.write() { Ok(g) => g, - Err(_) => return Response::Error { message: "internal error: frecency lock poisoned".into() }, + Err(_) => { + return Response::Error { + message: "internal error: frecency lock poisoned".into(), + }; + } }; frecency_guard.record_launch(item_id); Response::Ack @@ -360,7 +380,11 @@ impl Server { Request::Providers => { let pm_guard = match pm.read() { Ok(g) => g, - Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() }, + Err(_) => { + return Response::Error { + message: "internal error: provider lock poisoned".into(), + }; + } }; let descs = pm_guard.available_providers(); Response::Providers { @@ -371,7 +395,11 @@ impl Server { Request::Refresh { provider } => { let mut pm_guard = match pm.write() { Ok(g) => g, - Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() }, + Err(_) => { + return Response::Error { + message: "internal error: provider lock poisoned".into(), + }; + } }; pm_guard.refresh_provider(provider); Response::Ack @@ -385,7 +413,11 @@ impl Server { Request::Submenu { plugin_id, data } => { let pm_guard = match pm.read() { Ok(g) => g, - Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() }, + Err(_) => { + return Response::Error { + message: "internal error: provider lock poisoned".into(), + }; + } }; match pm_guard.query_submenu_actions(plugin_id, data, plugin_id) { Some((_name, actions)) => Response::SubmenuItems { @@ -403,7 +435,11 @@ impl Server { Request::PluginAction { command } => { let pm_guard = match pm.read() { Ok(g) => g, - Err(_) => return Response::Error { message: "internal error: provider lock poisoned".into() }, + Err(_) => { + return Response::Error { + message: "internal error: provider lock poisoned".into(), + }; + } }; if pm_guard.execute_plugin_action(command) { Response::Ack @@ -413,7 +449,6 @@ impl Server { } } } - } } } diff --git a/crates/owlry/src/ui/main_window.rs b/crates/owlry/src/ui/main_window.rs index 109f40d..8fb473c 100644 --- a/crates/owlry/src/ui/main_window.rs +++ b/crates/owlry/src/ui/main_window.rs @@ -1,4 +1,8 @@ use crate::backend::SearchBackend; +use crate::config::Config; +use crate::filter::ProviderFilter; +use crate::ipc::ProviderDesc; +use crate::providers::{ItemSource, LaunchItem, ProviderType}; use crate::ui::ResultRow; use crate::ui::provider_meta; use crate::ui::submenu; @@ -9,10 +13,6 @@ use gtk4::{ ListBoxRow, Orientation, ScrolledWindow, SelectionMode, ToggleButton, }; use log::info; -use crate::config::Config; -use crate::filter::ProviderFilter; -use crate::ipc::ProviderDesc; -use crate::providers::{ItemSource, LaunchItem, ProviderType}; #[cfg(feature = "dev-logging")] use log::debug; @@ -156,10 +156,7 @@ impl MainWindow { let provider_descs = Rc::new(provider_descs); // Update mode label with resolved plugin name (if applicable) - mode_label.set_label(&Self::mode_display_name( - &filter.borrow(), - &provider_descs, - )); + mode_label.set_label(&Self::mode_display_name(&filter.borrow(), &provider_descs)); // Create toggle buttons for each enabled provider let filter_buttons = @@ -339,10 +336,7 @@ impl MainWindow { } /// Get the mode display name, resolving plugin names via IPC metadata. - fn mode_display_name( - filter: &ProviderFilter, - descs: &HashMap, - ) -> String { + fn mode_display_name(filter: &ProviderFilter, descs: &HashMap) -> String { let base = filter.mode_display_name(); // "Plugin" is the generic fallback — resolve it to the actual name if base == "Plugin" { @@ -366,10 +360,7 @@ impl MainWindow { provider_meta::resolve(provider, desc) } - fn build_placeholder( - filter: &ProviderFilter, - descs: &HashMap, - ) -> String { + fn build_placeholder(filter: &ProviderFilter, descs: &HashMap) -> String { let active: Vec = filter .enabled_providers() .iter() @@ -507,10 +498,7 @@ impl MainWindow { // Restore UI mode_label.set_label(&Self::mode_display_name(&filter.borrow(), descs)); hints_label.set_label(&Self::build_hints(&config.borrow().providers)); - search_entry.set_placeholder_text(Some(&Self::build_placeholder( - &filter.borrow(), - descs, - ))); + search_entry.set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow(), descs))); search_entry.set_text(&saved_search); // Trigger refresh by emitting changed signal @@ -641,13 +629,7 @@ impl MainWindow { let be = backend.borrow(); let f = filter.borrow(); let c = config.borrow(); - be.query_async( - &query_str, - max_results, - &f, - &c, - tag.as_deref(), - ) + be.query_async(&query_str, max_results, &f, &c, tag.as_deref()) }; if let Some(rx) = receiver { @@ -669,22 +651,18 @@ impl MainWindow { results_list_cb.remove_all(); let items = result.items; - let initial_count = - INITIAL_RESULTS.min(items.len()); + let initial_count = INITIAL_RESULTS.min(items.len()); for item in items.iter().take(initial_count) { let row = ResultRow::new(item, &query_for_highlight); results_list_cb.append(&row); } - if let Some(first_row) = - results_list_cb.row_at_index(0) - { + if let Some(first_row) = results_list_cb.row_at_index(0) { results_list_cb.select_row(Some(&first_row)); } - *current_results_cb.borrow_mut() = - items[..initial_count].to_vec(); + *current_results_cb.borrow_mut() = items[..initial_count].to_vec(); let mut lazy = lazy_state_cb.borrow_mut(); lazy.all_results = items; lazy.displayed_count = initial_count; @@ -714,8 +692,7 @@ impl MainWindow { results_list.select_row(Some(&first_row)); } - *current_results.borrow_mut() = - results[..initial_count].to_vec(); + *current_results.borrow_mut() = results[..initial_count].to_vec(); let mut lazy = lazy_state.borrow_mut(); lazy.all_results = results; lazy.query = query_str; @@ -837,10 +814,8 @@ impl MainWindow { } } mode_label.set_label(&Self::mode_display_name(&filter.borrow(), &descs)); - search_entry.set_placeholder_text(Some(&Self::build_placeholder( - &filter.borrow(), - &descs, - ))); + search_entry + .set_placeholder_text(Some(&Self::build_placeholder(&filter.borrow(), &descs))); search_entry.emit_by_name::<()>("changed", &[]); }); } @@ -927,7 +902,12 @@ impl MainWindow { let next_index = current.index() + 1; if let Some(next_row) = results_list.row_at_index(next_index) { results_list.select_row(Some(&next_row)); - Self::scroll_to_row(&scrolled, &results_list, &next_row, &lazy_state_for_keys); + Self::scroll_to_row( + &scrolled, + &results_list, + &next_row, + &lazy_state_for_keys, + ); } } gtk4::glib::Propagation::Stop @@ -939,7 +919,12 @@ impl MainWindow { && let Some(prev_row) = results_list.row_at_index(prev_index) { results_list.select_row(Some(&prev_row)); - Self::scroll_to_row(&scrolled, &results_list, &prev_row, &lazy_state_for_keys); + Self::scroll_to_row( + &scrolled, + &results_list, + &prev_row, + &lazy_state_for_keys, + ); } } gtk4::glib::Propagation::Stop @@ -1212,12 +1197,10 @@ impl MainWindow { let max_results = cfg.general.max_results; drop(cfg); - let results = backend.borrow_mut().search( - "", - max_results, - &filter.borrow(), - &config.borrow(), - ); + let results = + backend + .borrow_mut() + .search("", max_results, &filter.borrow(), &config.borrow()); // Clear existing results results_list.remove_all(); diff --git a/crates/owlry/src/ui/result_row.rs b/crates/owlry/src/ui/result_row.rs index ab4b796..48d81b5 100644 --- a/crates/owlry/src/ui/result_row.rs +++ b/crates/owlry/src/ui/result_row.rs @@ -1,6 +1,6 @@ +use crate::providers::{LaunchItem, ProviderType}; use gtk4::prelude::*; use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget}; -use crate::providers::{LaunchItem, ProviderType}; #[allow(dead_code)] pub struct ResultRow { @@ -107,9 +107,7 @@ impl ResultRow { } else { // Default icon based on provider type (only core types, plugins should provide icons) let default_icon = match &item.provider { - crate::providers::ProviderType::Application => { - "application-x-executable-symbolic" - } + crate::providers::ProviderType::Application => "application-x-executable-symbolic", crate::providers::ProviderType::Command => "utilities-terminal-symbolic", crate::providers::ProviderType::Dmenu => "view-list-symbolic", // Plugins should provide their own icon; fallback to generic addon icon From a3e134e6b750e5f45030e7f40829ed7e377ecb9f Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 02:30:39 +0200 Subject: [PATCH 13/23] docs(v2): Phase 1 acceptance results + close-out Phase 1 (repo collapse) of the v2 restructure is complete. All 11 tasks landed; the acceptance checklist in section 9 passes end-to-end. Captured in section 10: - Per-task commit log (a4a903 -> c48efaa) - Each acceptance check (build, test, clippy, fmt, file layout, runtime smoke) with the result - Three deferred follow-ups that don't block 2.0.0 ship: dynamic-provider visibility in doctor, clippy style nits, refresh_widgets stub Next: Phase 2 (AUR republish as 2.0.0). The single owlry PKGBUILD declares replaces/conflicts/provides for the 14 dropped packages and builds with --features full. --- docs/RESTRUCTURE-V2.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/RESTRUCTURE-V2.md b/docs/RESTRUCTURE-V2.md index 560ae40..14f6507 100644 --- a/docs/RESTRUCTURE-V2.md +++ b/docs/RESTRUCTURE-V2.md @@ -454,7 +454,35 @@ This section captures in-progress state. Update freely as work proceeds. - `1d20754` — TDD characterization pass (+36 tests) - `0a4a090` — Workspace collapse: owlry-core merged into owlry (task #2) - `eb8a65f` — systemd provider converted (issue #5 functional fix in v2) - - (next) — Remaining 6 plugins converted in parallel: bookmarks, clipboard, emoji, filesearch, ssh, websearch (tasks #6 + #7) + - `cb2ea59` — Remaining 6 plugins converted in parallel: bookmarks, clipboard, emoji, filesearch, ssh, websearch (tasks #6 + #7) + - `d1c3270` + `1ba0a97` — sys → power rename (task #8) + - `27e2683` — CLI subcommand restructure + doctor/providers/config/migrate-config (task #9) + - `e9f310d` — auto-mode integration test (task #10) + - `c48efaa` — cargo fmt pass + +**Phase 1 status: COMPLETE** — 11/11 tasks done. Ready for Phase 2 (AUR republish as 2.0.0). + +Phase 1 acceptance results: +- ✅ `cargo check --workspace` (no default features) — clean +- ✅ `cargo check --workspace --features full` — clean +- ✅ `cargo build --release --features full` — 1m 19s +- ✅ `cargo test --workspace --features full` — **252 tests pass** (225 unit + 5 auto-mode + 14 ipc + 8 server) +- ✅ `cargo clippy --workspace --features full` — only 10 style warnings (`sort_by_key` suggestions, all benign) +- ✅ `cargo fmt --all --check` — clean after `c48efaa` +- ✅ Workspace contains only `crates/owlry` member +- ✅ `crates/owlry-core/`, `crates/owlry-plugin-api/`, `crates/owlry-lua/`, `crates/owlry-rune/` removed +- ✅ No occurrences of `owlry_plugin_api`, `mlua`, `libloading`, `liblua.so`, `librune.so` +- ✅ `target/release/owlry --help` shows new subcommand structure +- ✅ `owlry -d` starts daemon, socket binds at `$XDG_RUNTIME_DIR/owlry/owlry.sock` +- ✅ `owlry doctor` reports config + socket + 8 provider IDs +- ✅ `owlry providers` lists all registered providers; `owlry providers uuctl` shows the systemd provider details (id, prefix, icon, etc.) +- ✅ Issue #5 functionally fixed: systemd provider is compiled in (verified by parse-output unit tests; full live verification needs the binary installed against real systemd, but the C-ABI failure mode physically can't recur) +- ⚠️ Daemon double-spawn: pre-existing PIDs 3042 + 3278 still around (not Phase 1 work; addressed in Phase 5 hygiene) + +Known follow-ups (deferred, not blocking 2.0.0 ship): +- Dynamic providers (calc, conv, filesearch, websearch) don't appear in `available_providers()` — pre-existing behavior matched the old impl. Worth surfacing in `doctor` later. +- 10 clippy `sort_by_key` style warnings — Phase 5 cleanup. +- `refresh_widgets()` no-op stub in `backend.rs` — remove when widgets are revisited (D20 work). - **Tasks done:** #1 inventory, #3 delete C-ABI, #4 delete Rune+Lua crates, #5 delete config_editor (scripts never lived in this repo) - **Tasks remaining (Phase 1):** #2 workspace collapse, #6 convert 8 plugins, #7 cargo features, #8 sys→power rename, #9 CLI subcommands, #10 auto-mode test, #11 final build+smoke - **Stray processes from inventory phase:** From 0376abddae604e0cdba1597f65d6dd0c7dbfaeda Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 02:35:13 +0200 Subject: [PATCH 14/23] feat(paths): OWLRY_SOCKET env var to override the IPC socket path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a one-shot override before XDG_RUNTIME_DIR resolution so smoke tests (and side-by-side daemon instances during dev) can run without disturbing a production daemon listening on the default socket. - OWLRY_SOCKET=/tmp/foo.sock owlry -d - OWLRY_SOCKET=/tmp/foo.sock owlry providers Test added (owlry_socket_env_overrides_xdg_runtime) verifying the env var takes precedence and restoring the previous value so it doesn't leak across other tests in the module. Used to verify Phase 1 live behavior across all 13 provider entry points — each returned expected results, including uuctl resolving to real systemd unit names ('dbus' -> dbus-broker, at-spi-dbus-bus). Issue #5 confirmed fixed end-to-end. --- crates/owlry/src/paths.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/owlry/src/paths.rs b/crates/owlry/src/paths.rs index 7da39a7..9079eb2 100644 --- a/crates/owlry/src/paths.rs +++ b/crates/owlry/src/paths.rs @@ -157,10 +157,17 @@ pub fn system_data_dirs() -> Vec { // Runtime files // ============================================================================= -/// IPC socket path: `$XDG_RUNTIME_DIR/owlry/owlry.sock` +/// IPC socket path. /// -/// Falls back to `/tmp` if `$XDG_RUNTIME_DIR` is not set. +/// Resolution order: +/// 1. `$OWLRY_SOCKET` — full path override (useful for tests and side-by-side +/// daemon instances). +/// 2. `$XDG_RUNTIME_DIR/owlry/owlry.sock` (the normal case). +/// 3. `/tmp/owlry/owlry.sock` as a last-resort fallback. pub fn socket_path() -> PathBuf { + if let Ok(custom) = std::env::var("OWLRY_SOCKET") { + return PathBuf::from(custom); + } let runtime_dir = std::env::var("XDG_RUNTIME_DIR") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("/tmp")); @@ -202,6 +209,22 @@ mod tests { } } + #[test] + fn owlry_socket_env_overrides_xdg_runtime() { + // SAFETY: tests in this binary do not run in parallel against the env. + // Save / restore so other tests in this module aren't affected. + let prev = std::env::var_os("OWLRY_SOCKET"); + unsafe { std::env::set_var("OWLRY_SOCKET", "/tmp/owlry-smoke/foo.sock") }; + assert_eq!( + socket_path(), + PathBuf::from("/tmp/owlry-smoke/foo.sock") + ); + match prev { + Some(v) => unsafe { std::env::set_var("OWLRY_SOCKET", v) }, + None => unsafe { std::env::remove_var("OWLRY_SOCKET") }, + } + } + #[test] fn test_frecency_in_data_dir() { if let Some(path) = frecency_file() { From 1dd945d0b5331c2c0b3e060392c2610d7b37432a Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 02:35:28 +0200 Subject: [PATCH 15/23] docs(v2): record live smoke results across all 13 provider entry points Updates Phase 1 acceptance section with the per-provider live verification done after the OWLRY_SOCKET env var landed. Replaces the earlier 'unit-tests-imply-correctness' note for issue #5 with the actual live-daemon evidence. --- docs/RESTRUCTURE-V2.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/RESTRUCTURE-V2.md b/docs/RESTRUCTURE-V2.md index 14f6507..3eeb438 100644 --- a/docs/RESTRUCTURE-V2.md +++ b/docs/RESTRUCTURE-V2.md @@ -459,6 +459,7 @@ This section captures in-progress state. Update freely as work proceeds. - `27e2683` — CLI subcommand restructure + doctor/providers/config/migrate-config (task #9) - `e9f310d` — auto-mode integration test (task #10) - `c48efaa` — cargo fmt pass + - `0376abd` — OWLRY_SOCKET env override (smoke-test enabler) **Phase 1 status: COMPLETE** — 11/11 tasks done. Ready for Phase 2 (AUR republish as 2.0.0). @@ -476,7 +477,8 @@ Phase 1 acceptance results: - ✅ `owlry -d` starts daemon, socket binds at `$XDG_RUNTIME_DIR/owlry/owlry.sock` - ✅ `owlry doctor` reports config + socket + 8 provider IDs - ✅ `owlry providers` lists all registered providers; `owlry providers uuctl` shows the systemd provider details (id, prefix, icon, etc.) -- ✅ Issue #5 functionally fixed: systemd provider is compiled in (verified by parse-output unit tests; full live verification needs the binary installed against real systemd, but the C-ABI failure mode physically can't recur) +- ✅ Issue #5 functionally fixed: systemd provider compiled in AND verified live — `owlry providers uuctl dbus` returns dbus-broker + at-spi-dbus-bus from the running daemon's real `systemctl --user list-units` invocation. 43 user units loaded at refresh time. +- ✅ Every provider entry point smoke-tested with proper `modes` parameter: 13/13 produced expected output (app→Firefox, cmd→git*, power→Shutdown, bookmarks→GitHub*, clipboard→50 entries, emoji→heart*, ssh→aur.archlinux.org, uuctl→dbus-broker, calc 2+2→4, conv 5 ft to m→1.524 m, websearch→Search URL, filesearch→file matches). - ⚠️ Daemon double-spawn: pre-existing PIDs 3042 + 3278 still around (not Phase 1 work; addressed in Phase 5 hygiene) Known follow-ups (deferred, not blocking 2.0.0 ship): From 38057b36e379d508d3e1f07add58d4512b5899c2 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 02:43:22 +0200 Subject: [PATCH 16/23] =?UTF-8?q?build(v2):=20Phase=202=20local=20prep=20?= =?UTF-8?q?=E2=80=94=20PKGBUILD,=20units=20rename,=20.install=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stages everything needed for the AUR 2.0.0 republish, without pushing or publishing. The next checkpoint is a local makepkg test (task #2.5); push/publish actions wait for explicit go-ahead (task #2.6). aur/owlry/PKGBUILD: - pkgver 1.0.10 -> 2.0.0; pkgrel 1 - depends drops owlry-core (now folded into owlry) - optdepends cleaned: just cliphist, wl-clipboard, fd, mlocate — the external tools providers shell out to. No more 11 plugin packages. - build uses --features full (AUR ships everything compiled in; cargo install consumers still get the minimal default) - check runs cargo test --features full - package installs single binary + renamed systemd units + docs/themes - replaces/conflicts/provides cover 18 dropped packages: owlry-core, owlry-lua, owlry-rune, 11 owlry-plugin-* (including the deferred widgets per D20), 4 owlry-meta-* aur/owlry/owlry.install (new): - post_install message: how to start the daemon - post_upgrade from 1.x: announce the systemd unit rename and tell the user to disable old owlryd.service / enable new owlry.service. Includes a banner with the v2 breaking changes (widgets gone, plugins built in) - post_remove note: config stays systemd/: - owlryd.service -> owlry.service (per D15) - owlryd.socket -> owlry.socket crates/owlry/src/client.rs: - connect_or_start invokes 'systemctl --user start owlry.service' justfile: - install-local installs renamed units aur/owlry-{core,lua,rune}/: - Tracked files (PKGBUILD, .SRCINFO, .gitignore) removed from main repo - .gitignore entries added so the leftover local checkouts (still on disk with their AUR-remote .git dirs) don't keep showing as untracked - AUR remotes themselves unaffected; orphaning on aur.archlinux.org is a separate manual step aur/owlry/.SRCINFO regenerated via makepkg --printsrcinfo. --- .gitignore | 8 ++ aur/owlry-core/.SRCINFO | 14 ---- aur/owlry-core/.gitignore | 10 --- aur/owlry-core/PKGBUILD | 41 ---------- aur/owlry-lua/.SRCINFO | 15 ---- aur/owlry-lua/.gitignore | 10 --- aur/owlry-lua/PKGBUILD | 41 ---------- aur/owlry-rune/.SRCINFO | 14 ---- aur/owlry-rune/.gitignore | 10 --- aur/owlry-rune/PKGBUILD | 41 ---------- aur/owlry/.SRCINFO | 86 ++++++++++++++------ aur/owlry/PKGBUILD | 98 ++++++++++++++--------- aur/owlry/owlry.install | 70 ++++++++++++++++ crates/owlry/src/client.rs | 6 +- justfile | 6 +- systemd/{owlryd.service => owlry.service} | 0 systemd/{owlryd.socket => owlry.socket} | 0 17 files changed, 209 insertions(+), 261 deletions(-) delete mode 100644 aur/owlry-core/.SRCINFO delete mode 100644 aur/owlry-core/.gitignore delete mode 100644 aur/owlry-core/PKGBUILD delete mode 100644 aur/owlry-lua/.SRCINFO delete mode 100644 aur/owlry-lua/.gitignore delete mode 100644 aur/owlry-lua/PKGBUILD delete mode 100644 aur/owlry-rune/.SRCINFO delete mode 100644 aur/owlry-rune/.gitignore delete mode 100644 aur/owlry-rune/PKGBUILD create mode 100644 aur/owlry/owlry.install rename systemd/{owlryd.service => owlry.service} (100%) rename systemd/{owlryd.socket => owlry.socket} (100%) diff --git a/.gitignore b/.gitignore index 845a885..f9e94d0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,11 @@ aur/*/*.pkg.tar.* build-logs/ test-build-output*.md test-build-output*.log + +# v2: local AUR checkouts of dropped packages. The AUR remotes still exist +# on aur.archlinux.org until manually orphaned via the AUR web UI; the +# embedded .git dirs here are pointers to those remotes. Ignored so the +# main repo doesn't try to track them as embedded submodules. +/aur/owlry-core/ +/aur/owlry-lua/ +/aur/owlry-rune/ diff --git a/aur/owlry-core/.SRCINFO b/aur/owlry-core/.SRCINFO deleted file mode 100644 index 0c4d945..0000000 --- a/aur/owlry-core/.SRCINFO +++ /dev/null @@ -1,14 +0,0 @@ -pkgbase = owlry-core - pkgdesc = Core daemon for the Owlry application launcher — manages plugins, providers, and search - pkgver = 1.3.6 - pkgrel = 1 - url = https://somegit.dev/Owlibou/owlry - arch = x86_64 - license = GPL-3.0-or-later - makedepends = cargo - depends = gcc-libs - depends = openssl - source = owlry-core-1.3.6.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v1.3.6.tar.gz - b2sums = be68489a6148ed3fb12c257994d1c1293a82b9cc2dd1f9a86cfdc780d27c0d07ed31b309b1119525880d605910f653ba7c35b49ae0472cb5a944bb39d36ebaa2 - -pkgname = owlry-core diff --git a/aur/owlry-core/.gitignore b/aur/owlry-core/.gitignore deleted file mode 100644 index 113d4f1..0000000 --- a/aur/owlry-core/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -*.pkg.tar.zst -*.pkg.tar.zst-namcap.log -*-namcap.log -*-build.log -*-check.log -*-package.log -*-prepare.log -*.tar.gz -src/ -pkg/ diff --git a/aur/owlry-core/PKGBUILD b/aur/owlry-core/PKGBUILD deleted file mode 100644 index be120cf..0000000 --- a/aur/owlry-core/PKGBUILD +++ /dev/null @@ -1,41 +0,0 @@ -# Maintainer: vikingowl -pkgname=owlry-core -pkgver=1.3.6 -pkgrel=1 -pkgdesc='Core daemon for the Owlry application launcher — manages plugins, providers, and search' -arch=('x86_64') -url='https://somegit.dev/Owlibou/owlry' -license=('GPL-3.0-or-later') -depends=('gcc-libs' 'openssl') -makedepends=('cargo') -source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-core-v$pkgver.tar.gz") -b2sums=('be68489a6148ed3fb12c257994d1c1293a82b9cc2dd1f9a86cfdc780d27c0d07ed31b309b1119525880d605910f653ba7c35b49ae0472cb5a944bb39d36ebaa2') - -prepare() { - cd "owlry" - export RUSTUP_TOOLCHAIN=stable - cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" -} - -build() { - cd "owlry" - export RUSTUP_TOOLCHAIN=stable - export CARGO_TARGET_DIR=target - cargo build -p owlry-core --frozen --release -} - -check() { - cd "owlry" - export RUSTUP_TOOLCHAIN=stable - export CARGO_TARGET_DIR=target - cargo test -p owlry-core --frozen --lib -} - -package() { - cd "owlry" - install -Dm755 "target/release/owlryd" "$pkgdir/usr/bin/owlryd" - install -Dm644 "systemd/owlryd.service" "$pkgdir/usr/lib/systemd/user/owlryd.service" - install -Dm644 "systemd/owlryd.socket" "$pkgdir/usr/lib/systemd/user/owlryd.socket" - install -dm755 "$pkgdir/usr/lib/owlry/plugins" - install -dm755 "$pkgdir/usr/lib/owlry/runtimes" -} diff --git a/aur/owlry-lua/.SRCINFO b/aur/owlry-lua/.SRCINFO deleted file mode 100644 index ddc98e8..0000000 --- a/aur/owlry-lua/.SRCINFO +++ /dev/null @@ -1,15 +0,0 @@ -pkgbase = owlry-lua - pkgdesc = Lua scripting runtime for Owlry — enables user-created Lua plugins - pkgver = 1.1.5 - pkgrel = 1 - url = https://somegit.dev/Owlibou/owlry - arch = x86_64 - license = GPL-3.0-or-later - makedepends = cargo - depends = gcc-libs - depends = lua54 - optdepends = owlry-core: daemon that loads this runtime - source = owlry-lua-1.1.5.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v1.1.5.tar.gz - b2sums = be68489a6148ed3fb12c257994d1c1293a82b9cc2dd1f9a86cfdc780d27c0d07ed31b309b1119525880d605910f653ba7c35b49ae0472cb5a944bb39d36ebaa2 - -pkgname = owlry-lua diff --git a/aur/owlry-lua/.gitignore b/aur/owlry-lua/.gitignore deleted file mode 100644 index 113d4f1..0000000 --- a/aur/owlry-lua/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -*.pkg.tar.zst -*.pkg.tar.zst-namcap.log -*-namcap.log -*-build.log -*-check.log -*-package.log -*-prepare.log -*.tar.gz -src/ -pkg/ diff --git a/aur/owlry-lua/PKGBUILD b/aur/owlry-lua/PKGBUILD deleted file mode 100644 index 33b6a38..0000000 --- a/aur/owlry-lua/PKGBUILD +++ /dev/null @@ -1,41 +0,0 @@ -# Maintainer: vikingowl -pkgname=owlry-lua -pkgver=1.1.5 -pkgrel=1 -pkgdesc="Lua scripting runtime for Owlry — enables user-created Lua plugins" -arch=('x86_64') -url="https://somegit.dev/Owlibou/owlry" -license=('GPL-3.0-or-later') -depends=('gcc-libs' 'lua54') -optdepends=('owlry-core: daemon that loads this runtime') -makedepends=('cargo') -source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-lua-v$pkgver.tar.gz") -b2sums=('be68489a6148ed3fb12c257994d1c1293a82b9cc2dd1f9a86cfdc780d27c0d07ed31b309b1119525880d605910f653ba7c35b49ae0472cb5a944bb39d36ebaa2') - -_cratename=owlry-lua - -prepare() { - cd "owlry" - export RUSTUP_TOOLCHAIN=stable - cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" -} - -build() { - cd "owlry" - export RUSTUP_TOOLCHAIN=stable - export CARGO_TARGET_DIR=target - cargo build -p $_cratename --frozen --release --no-default-features -} - -check() { - cd "owlry" - export RUSTUP_TOOLCHAIN=stable - export CARGO_TARGET_DIR=target - cargo test -p $_cratename --frozen --no-default-features --lib -} - -package() { - cd "owlry" - install -Dm755 "target/release/lib${_cratename//-/_}.so" \ - "$pkgdir/usr/lib/owlry/runtimes/liblua.so" -} diff --git a/aur/owlry-rune/.SRCINFO b/aur/owlry-rune/.SRCINFO deleted file mode 100644 index 13ae079..0000000 --- a/aur/owlry-rune/.SRCINFO +++ /dev/null @@ -1,14 +0,0 @@ -pkgbase = owlry-rune - pkgdesc = Rune scripting runtime for Owlry — enables user-created Rune plugins - pkgver = 1.1.6 - pkgrel = 1 - url = https://somegit.dev/Owlibou/owlry - arch = x86_64 - license = GPL-3.0-or-later - makedepends = cargo - depends = gcc-libs - optdepends = owlry-core: daemon that loads this runtime - source = owlry-rune-1.1.6.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v1.1.6.tar.gz - b2sums = be68489a6148ed3fb12c257994d1c1293a82b9cc2dd1f9a86cfdc780d27c0d07ed31b309b1119525880d605910f653ba7c35b49ae0472cb5a944bb39d36ebaa2 - -pkgname = owlry-rune diff --git a/aur/owlry-rune/.gitignore b/aur/owlry-rune/.gitignore deleted file mode 100644 index 113d4f1..0000000 --- a/aur/owlry-rune/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -*.pkg.tar.zst -*.pkg.tar.zst-namcap.log -*-namcap.log -*-build.log -*-check.log -*-package.log -*-prepare.log -*.tar.gz -src/ -pkg/ diff --git a/aur/owlry-rune/PKGBUILD b/aur/owlry-rune/PKGBUILD deleted file mode 100644 index 939d76e..0000000 --- a/aur/owlry-rune/PKGBUILD +++ /dev/null @@ -1,41 +0,0 @@ -# Maintainer: vikingowl -pkgname=owlry-rune -pkgver=1.1.6 -pkgrel=1 -pkgdesc="Rune scripting runtime for Owlry — enables user-created Rune plugins" -arch=('x86_64') -url="https://somegit.dev/Owlibou/owlry" -license=('GPL-3.0-or-later') -depends=('gcc-libs') -optdepends=('owlry-core: daemon that loads this runtime') -makedepends=('cargo') -source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-rune-v$pkgver.tar.gz") -b2sums=('be68489a6148ed3fb12c257994d1c1293a82b9cc2dd1f9a86cfdc780d27c0d07ed31b309b1119525880d605910f653ba7c35b49ae0472cb5a944bb39d36ebaa2') - -_cratename=owlry-rune - -prepare() { - cd "owlry" - export RUSTUP_TOOLCHAIN=stable - cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" -} - -build() { - cd "owlry" - export RUSTUP_TOOLCHAIN=stable - export CARGO_TARGET_DIR=target - cargo build -p $_cratename --frozen --release -} - -check() { - cd "owlry" - export RUSTUP_TOOLCHAIN=stable - export CARGO_TARGET_DIR=target - cargo test -p $_cratename --frozen --release -} - -package() { - cd "owlry" - install -Dm755 "target/release/lib${_cratename//-/_}.so" \ - "$pkgdir/usr/lib/owlry/runtimes/librune.so" -} diff --git a/aur/owlry/.SRCINFO b/aur/owlry/.SRCINFO index 0b5d2d1..7223204 100644 --- a/aur/owlry/.SRCINFO +++ b/aur/owlry/.SRCINFO @@ -1,34 +1,74 @@ pkgbase = owlry - pkgdesc = Lightweight Wayland application launcher with plugin support - pkgver = 1.0.10 + pkgdesc = Lightweight Wayland application launcher — UI, daemon, and providers in one binary + pkgver = 2.0.0 pkgrel = 1 url = https://somegit.dev/Owlibou/owlry + install = owlry.install arch = x86_64 license = GPL-3.0-or-later makedepends = cargo - depends = owlry-core depends = gcc-libs depends = gtk4 depends = gtk4-layer-shell - optdepends = cliphist: clipboard provider support - optdepends = wl-clipboard: clipboard and emoji copy support - optdepends = fd: fast file search - optdepends = owlry-plugin-calculator: calculator provider - optdepends = owlry-plugin-clipboard: clipboard provider - optdepends = owlry-plugin-emoji: emoji picker - optdepends = owlry-plugin-bookmarks: browser bookmarks - optdepends = owlry-plugin-ssh: SSH host launcher - optdepends = owlry-plugin-scripts: custom scripts provider - optdepends = owlry-plugin-system: system actions (shutdown, reboot, etc.) - optdepends = owlry-plugin-websearch: web search provider - optdepends = owlry-plugin-filesearch: file search provider - optdepends = owlry-plugin-systemd: systemd service management - optdepends = owlry-plugin-weather: weather widget - optdepends = owlry-plugin-media: media player controls - optdepends = owlry-plugin-pomodoro: pomodoro timer widget - optdepends = owlry-lua: Lua runtime for user plugins - optdepends = owlry-rune: Rune runtime for user plugins - source = owlry-1.0.10.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v1.0.10.tar.gz - b2sums = be68489a6148ed3fb12c257994d1c1293a82b9cc2dd1f9a86cfdc780d27c0d07ed31b309b1119525880d605910f653ba7c35b49ae0472cb5a944bb39d36ebaa2 + optdepends = cliphist: clipboard history provider + optdepends = wl-clipboard: clipboard write and emoji copy + optdepends = fd: filesystem search provider (primary backend) + optdepends = mlocate: filesystem search provider (fallback backend) + provides = owlry-core + provides = owlry-lua + provides = owlry-rune + provides = owlry-plugin-bookmarks + provides = owlry-plugin-clipboard + provides = owlry-plugin-emoji + provides = owlry-plugin-filesearch + provides = owlry-plugin-media + provides = owlry-plugin-pomodoro + provides = owlry-plugin-scripts + provides = owlry-plugin-ssh + provides = owlry-plugin-systemd + provides = owlry-plugin-weather + provides = owlry-plugin-websearch + provides = owlry-meta-essentials + provides = owlry-meta-widgets + provides = owlry-meta-tools + provides = owlry-meta-full + conflicts = owlry-core + conflicts = owlry-lua + conflicts = owlry-rune + conflicts = owlry-plugin-bookmarks + conflicts = owlry-plugin-clipboard + conflicts = owlry-plugin-emoji + conflicts = owlry-plugin-filesearch + conflicts = owlry-plugin-media + conflicts = owlry-plugin-pomodoro + conflicts = owlry-plugin-scripts + conflicts = owlry-plugin-ssh + conflicts = owlry-plugin-systemd + conflicts = owlry-plugin-weather + conflicts = owlry-plugin-websearch + conflicts = owlry-meta-essentials + conflicts = owlry-meta-widgets + conflicts = owlry-meta-tools + conflicts = owlry-meta-full + replaces = owlry-core + replaces = owlry-lua + replaces = owlry-rune + replaces = owlry-plugin-bookmarks + replaces = owlry-plugin-clipboard + replaces = owlry-plugin-emoji + replaces = owlry-plugin-filesearch + replaces = owlry-plugin-media + replaces = owlry-plugin-pomodoro + replaces = owlry-plugin-scripts + replaces = owlry-plugin-ssh + replaces = owlry-plugin-systemd + replaces = owlry-plugin-weather + replaces = owlry-plugin-websearch + replaces = owlry-meta-essentials + replaces = owlry-meta-widgets + replaces = owlry-meta-tools + replaces = owlry-meta-full + source = owlry-2.0.0.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v2.0.0.tar.gz + b2sums = SKIP pkgname = owlry diff --git a/aur/owlry/PKGBUILD b/aur/owlry/PKGBUILD index 532bfb6..dbe5231 100644 --- a/aur/owlry/PKGBUILD +++ b/aur/owlry/PKGBUILD @@ -1,35 +1,63 @@ # Maintainer: vikingowl pkgname=owlry -pkgver=1.0.10 +pkgver=2.0.0 pkgrel=1 -pkgdesc="Lightweight Wayland application launcher with plugin support" +pkgdesc="Lightweight Wayland application launcher — UI, daemon, and providers in one binary" arch=('x86_64') url="https://somegit.dev/Owlibou/owlry" license=('GPL-3.0-or-later') -depends=('owlry-core' 'gcc-libs' 'gtk4' 'gtk4-layer-shell') +depends=( + 'gcc-libs' + 'gtk4' + 'gtk4-layer-shell' +) makedepends=('cargo') optdepends=( - 'cliphist: clipboard provider support' - 'wl-clipboard: clipboard and emoji copy support' - 'fd: fast file search' - 'owlry-plugin-calculator: calculator provider' - 'owlry-plugin-clipboard: clipboard provider' - 'owlry-plugin-emoji: emoji picker' - 'owlry-plugin-bookmarks: browser bookmarks' - 'owlry-plugin-ssh: SSH host launcher' - 'owlry-plugin-scripts: custom scripts provider' - 'owlry-plugin-system: system actions (shutdown, reboot, etc.)' - 'owlry-plugin-websearch: web search provider' - 'owlry-plugin-filesearch: file search provider' - 'owlry-plugin-systemd: systemd service management' - 'owlry-plugin-weather: weather widget' - 'owlry-plugin-media: media player controls' - 'owlry-plugin-pomodoro: pomodoro timer widget' - 'owlry-lua: Lua runtime for user plugins' - 'owlry-rune: Rune runtime for user plugins' + 'cliphist: clipboard history provider' + 'wl-clipboard: clipboard write and emoji copy' + 'fd: filesystem search provider (primary backend)' + 'mlocate: filesystem search provider (fallback backend)' ) +# v2.0 replaces the entire pre-collapse package set. paru/pacman -Syu +# transparently swaps the old packages for owlry-2.0.0 via these arrays. +# Notes: +# - owlry-{core,lua,rune}: functionality merged into owlry; Lua runtime +# deferred to a later release with the Lua config layer (D4 / Phase 3). +# - owlry-plugin-*: every plugin became a feature-gated module in owlry. +# This PKGBUILD builds with --features full so all of them are present. +# - owlry-plugin-{weather,media,pomodoro}: widgets are deferred per D20. +# Listed here so users on those packages get a clean upgrade; widget +# functionality returns in a later 2.x release. +# - owlry-plugin-scripts: replaced by user Lua config (D12), Phase 3+. +# - owlry-meta-*: superseded by the single owlry package. +_v2_replaced=( + 'owlry-core' + 'owlry-lua' + 'owlry-rune' + 'owlry-plugin-bookmarks' + 'owlry-plugin-clipboard' + 'owlry-plugin-emoji' + 'owlry-plugin-filesearch' + 'owlry-plugin-media' + 'owlry-plugin-pomodoro' + 'owlry-plugin-scripts' + 'owlry-plugin-ssh' + 'owlry-plugin-systemd' + 'owlry-plugin-weather' + 'owlry-plugin-websearch' + 'owlry-meta-essentials' + 'owlry-meta-widgets' + 'owlry-meta-tools' + 'owlry-meta-full' +) +replaces=("${_v2_replaced[@]}") +conflicts=("${_v2_replaced[@]}") +provides=("${_v2_replaced[@]}") + +install=owlry.install + source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v$pkgver.tar.gz") -b2sums=('be68489a6148ed3fb12c257994d1c1293a82b9cc2dd1f9a86cfdc780d27c0d07ed31b309b1119525880d605910f653ba7c35b49ae0472cb5a944bb39d36ebaa2') +b2sums=('SKIP') # populated by `just aur-update-pkg owlry` once the tag is pushed prepare() { cd "owlry" @@ -41,37 +69,35 @@ build() { cd "owlry" export RUSTUP_TOOLCHAIN=stable export CARGO_TARGET_DIR=target - # Build only the core binary without embedded Lua (Lua runtime is separate package) - cargo build -p owlry --frozen --release --no-default-features + # 'full' enables every optional provider — the AUR binary is the + # batteries-included experience. cargo install consumers can still opt + # to --no-default-features and pick their own subset. + cargo build --frozen --release --features full } check() { cd "owlry" export RUSTUP_TOOLCHAIN=stable export CARGO_TARGET_DIR=target - cargo test -p owlry --frozen --no-default-features + cargo test --frozen --release --features full } package() { cd "owlry" - # Core binary + # Single binary. install -Dm755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname" - # Documentation - install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" + # systemd user units (renamed from owlryd.* in v2 — see D15). + install -Dm644 systemd/owlry.service "$pkgdir/usr/lib/systemd/user/owlry.service" + install -Dm644 systemd/owlry.socket "$pkgdir/usr/lib/systemd/user/owlry.socket" - # Example configuration files + # Documentation + example configuration. + install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" install -Dm644 data/config.example.toml "$pkgdir/usr/share/doc/$pkgname/config.example.toml" install -Dm644 data/style.example.css "$pkgdir/usr/share/doc/$pkgname/style.example.css" - install -Dm755 data/scripts/example.sh "$pkgdir/usr/share/doc/$pkgname/scripts/example.sh" - # Install themes + # Themes. install -d "$pkgdir/usr/share/$pkgname/themes" install -Dm644 data/themes/*.css "$pkgdir/usr/share/$pkgname/themes/" - - # Example plugins (for user plugin development) - install -d "$pkgdir/usr/share/$pkgname/examples/plugins" - cp -r examples/plugins/* "$pkgdir/usr/share/$pkgname/examples/plugins/" - chmod -R a+rX "$pkgdir/usr/share/$pkgname/examples/plugins/" } diff --git a/aur/owlry/owlry.install b/aur/owlry/owlry.install new file mode 100644 index 0000000..66108b0 --- /dev/null +++ b/aur/owlry/owlry.install @@ -0,0 +1,70 @@ +## owlry .install hook +## +## v2.0 renamed the systemd user units (owlryd.{service,socket} → owlry.{service,socket}) +## and consolidated 14 separate packages into one. Handle the unit migration so users +## upgrading from 1.x don't end up with an enabled-but-missing owlryd.service. + +post_upgrade() { + local old_pkgver="$2" + case "$old_pkgver" in + 1.*|0.*) + cat <<'EOF' + + ╭─────────────────────────────────────────────────────────────────╮ + │ owlry 2.0 — the v2 rewrite │ + │ │ + │ The daemon binary 'owlryd' is gone; owlry now ships as a │ + │ single binary. Use `owlry -d` (or the systemd service). │ + │ │ + │ The systemd user unit was renamed: │ + │ owlryd.service -> owlry.service │ + │ owlryd.socket -> owlry.socket │ + │ │ + │ If you had the old service enabled, run: │ + │ systemctl --user disable --now owlryd.service │ + │ systemctl --user enable --now owlry.service │ + │ │ + │ Plugin packages (bookmarks, systemd, clipboard, …) are now │ + │ built into owlry by default — they were dropped from AUR and │ + │ replaced via this package. │ + │ │ + │ Widget providers (weather, media, pomodoro) are not in 2.0; │ + │ they return in a later 2.x release. See: │ + │ docs/RESTRUCTURE-V2.md (decisions D20, section 8) │ + ╰─────────────────────────────────────────────────────────────────╯ + +EOF + # Best-effort transition: if the old owlryd.service is enabled or + # active for the invoking user, stop it and disable it so the new + # owlry.service can take over. Errors are non-fatal — pacman runs + # as root, so we can only inspect, not toggle user units here. + if command -v loginctl >/dev/null 2>&1; then + local invoking_user + invoking_user="$(loginctl list-users --no-legend 2>/dev/null | awk 'NR==1 {print $2}')" + if [ -n "$invoking_user" ]; then + echo " (Run the systemctl --user commands above as user '$invoking_user'.)" + fi + fi + ;; + esac +} + +post_install() { + cat <<'EOF' + + owlry installed. Start the daemon with: + systemctl --user enable --now owlry.service + + Or run ad-hoc: + owlry -d & + owlry + + Configuration: ~/.config/owlry/config.toml + Diagnostics: owlry doctor + +EOF +} + +post_remove() { + echo " owlry removed. Configuration in ~/.config/owlry remains intact." +} diff --git a/crates/owlry/src/client.rs b/crates/owlry/src/client.rs index bb97ef8..3bd7da1 100644 --- a/crates/owlry/src/client.rs +++ b/crates/owlry/src/client.rs @@ -77,13 +77,13 @@ impl CoreClient { // Socket not available — try to start the daemon. let status = std::process::Command::new("systemctl") - .args(["--user", "start", "owlryd"]) + .args(["--user", "start", "owlry.service"]) .status() - .map_err(|e| io::Error::other(format!("failed to start owlryd via systemd: {e}")))?; + .map_err(|e| io::Error::other(format!("failed to start owlry.service via systemd: {e}")))?; if !status.success() { return Err(io::Error::other(format!( - "systemctl --user start owlryd exited with status {}", + "systemctl --user start owlry.service exited with status {}", status ))); } diff --git a/justfile b/justfile index 55625f6..772b582 100644 --- a/justfile +++ b/justfile @@ -47,10 +47,10 @@ install-local: sudo install -Dm755 target/release/owlry /usr/bin/owlry echo "Installing systemd service files..." - sudo install -Dm644 systemd/owlryd.service /usr/lib/systemd/user/owlryd.service - sudo install -Dm644 systemd/owlryd.socket /usr/lib/systemd/user/owlryd.socket + sudo install -Dm644 systemd/owlry.service /usr/lib/systemd/user/owlry.service + sudo install -Dm644 systemd/owlry.socket /usr/lib/systemd/user/owlry.socket - echo "Done. Start daemon: systemctl --user enable --now owlryd.service" + echo "Done. Start daemon: systemctl --user enable --now owlry.service" # === Version Management === diff --git a/systemd/owlryd.service b/systemd/owlry.service similarity index 100% rename from systemd/owlryd.service rename to systemd/owlry.service diff --git a/systemd/owlryd.socket b/systemd/owlry.socket similarity index 100% rename from systemd/owlryd.socket rename to systemd/owlry.socket From b22e1a52fb0c98ec4a60b501f82903f666153c5c Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 02:55:01 +0200 Subject: [PATCH 17/23] docs(v2): refresh README, ROADMAP, CLAUDE.md + expand replaces array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-facing docs that lagged behind the v2 rewrite. Replaces the 1.x package-mosaic narrative everywhere with the single-binary 2.0 reality. README.md (rewrite): - Highlights single binary + cargo-feature-gated providers - Drops the 11-plugin Available Packages table (single package now) - Drops Settings Editor section (config_editor was deleted) - Drops Plugin Management CLI section (entire 'owlry plugin ...' tree is gone; commands.rs handles doctor / providers / config / migrate) - New cargo features table (matches owlry/Cargo.toml [features]) - Build-from-source uses cargo build --release --features full - All owlryd refs -> owlry -d / owlry.service - Search prefixes table updated for sys->power rename - Architecture diagram redrawn for single binary - New 'Roadmap' subsection pointing at upcoming Lua config + widgets ROADMAP.md (revise): - Drops 'Plugin hot-reload' (compiled-in providers can't hot-reload) - Drops 'Plugin settings UI' (config_editor was the v1 equivalent; gone) - Drops 'Plugin marketplace' / Lua plugin install via CLI (the install surface itself was removed in v2; user extensions return via Phase 3 Lua config, not via a registry) - Drops 'Split monorepo' / 'Plugin API backwards compatibility' / 'Per-plugin config' — all resolved by the v2 collapse - Adds 'Lua-driven configuration' and 'Widget providers return' as the next-up bets - Notes Phase 5 hygiene items (file splits, submenu protocol redesign, double-spawn) so they're not lost CLAUDE.md (rewrite for v2): - Project shape diagram = single crate, single binary - Build commands use --features full - OWLRY_SOCKET env var documented (smoke-test enabler) - Naming-rules table covers the sys->power and owlryd->owlry transitions so future AI assistants don't reinvent the old vocabulary - Drops references to owlry-core, owlry-plugin-api, owlry-lua, owlry-rune - Drops plugin CLI documentation (subcommand tree is gone) aur/owlry/PKGBUILD: - replaces / conflicts / provides arrays grow from 18 -> 21 entries. - Adds three plugin packages I'd missed in the first pass: owlry-plugin-calculator (pre-v2 transitional stub, pkgrel -99) owlry-plugin-converter (same) owlry-plugin-system (same — distinct from owlry-plugin-systemd!) - Arrays now organised by reason (folded plugins / deferred widgets / Lua-replaced / pre-v2 stubs / meta-bundles) with inline comments. - .SRCINFO regenerated. Local rebuild verified: makepkg produces owlry-2.0.0-1-x86_64.pkg.tar.zst with 21 conflict= entries in .PKGINFO. --- CLAUDE.md | 516 ++++++++++----------------------------------- README.md | 474 +++++++++++++---------------------------- ROADMAP.md | 92 ++++---- aur/owlry/.SRCINFO | 33 +-- aur/owlry/PKGBUILD | 19 +- 5 files changed, 331 insertions(+), 803 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fb92845..a7b4947 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,442 +1,142 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Guidance for Claude Code (claude.ai/code) when working in this repository. -## Build & Development Commands +The v2 rewrite is the load-bearing context here. Read [`docs/RESTRUCTURE-V2.md`](docs/RESTRUCTURE-V2.md) first if you're picking the project back up — it captures every design decision (D1–D21), the phased plan, and the per-task commit log. + +## Project shape + +Owlry is a single-binary Wayland launcher. Workspace has exactly one member: + +``` +crates/owlry/ -- everything +├── Cargo.toml -- one binary + one library, features per provider +├── src/ +│ ├── main.rs -- subcommand router +│ ├── cli.rs -- clap definitions +│ ├── lib.rs -- module re-exports for integration tests +│ ├── server.rs -- IPC daemon (bind + accept loop) +│ ├── client.rs -- IPC client (UI mode) +│ ├── backend.rs -- SearchBackend abstraction +│ ├── app.rs -- GTK4 setup +│ ├── ui/ -- GTK widgets +│ ├── config/ -- TOML config loader +│ ├── data/ -- FrecencyStore +│ ├── filter.rs -- ProviderFilter + prefix parser +│ ├── ipc.rs -- Request/Response types +│ ├── paths.rs -- XDG paths (honours $OWLRY_SOCKET) +│ ├── commands.rs -- subcommand dispatchers (doctor / providers / config / …) +│ └── providers/ -- ALL providers +│ ├── mod.rs -- Provider/DynamicProvider traits, ProviderManager +│ ├── application.rs (feature: app) +│ ├── command.rs (feature: cmd) +│ ├── calculator.rs (feature: calc) +│ ├── converter/ (feature: conv) +│ ├── power.rs (feature: power — pre-v2 name: sys/system) +│ ├── dmenu.rs (feature: dmenu) +│ ├── bookmarks.rs (feature: bookmarks) +│ ├── clipboard.rs (feature: clipboard) +│ ├── emoji.rs (feature: emoji) +│ ├── ssh.rs (feature: ssh) +│ ├── systemd.rs (feature: systemd — type_id "uuctl") +│ ├── websearch.rs (feature: websearch) +│ └── filesearch.rs (feature: filesearch) +``` + +There is no `crates/owlry-core`, `owlry-plugin-api`, `owlry-lua`, or `owlry-rune`. They were deleted in the v2 demolition. There is no `~/.config/owlry/plugins/` discovery directory and no `/usr/lib/owlry/plugins/*.so` loading — every "plugin" is a feature-gated module compiled into `owlry`. + +## Build & development ```bash -just build # Debug build (all workspace members) -just build-ui # UI binary only -just build-daemon # Core daemon only -just release # Release build (LTO, stripped) -just release-daemon # Release build for daemon only -just check # cargo check + clippy -just test # Run tests -just fmt # Format code -just run [ARGS] # Run UI with optional args (e.g., just run --mode app) -just run-daemon # Run core daemon -just install-local # Install core + daemon + runtimes + systemd units +# Default features (minimal — app, cmd, calc, conv, power, dmenu) +cargo build -# Dev build with verbose logging -cargo run -p owlry --features dev-logging +# Full features (matches AUR build) +cargo build --release --features full -# Build core without embedded Lua (smaller binary, uses external owlry-lua) -cargo build -p owlry --release --no-default-features +# Tests +cargo test --workspace --features full # 252+ tests +cargo test --workspace --no-default-features + +# Verbose dev logging +cargo run -- --features dev-logging -- -d + +# Format + lint +cargo fmt --all +cargo clippy --workspace --features full + +# Install locally (sudo) +just install-local ``` -## Usage Examples +The daemon binary is `owlry`. There is no separate `owlryd` binary anywhere — the daemon is `owlry -d` (or `owlry daemon`). The systemd user unit is `owlry.service` (pre-2.0 name: `owlryd.service`). -### Basic Invocation +## Running locally without disturbing prod -The UI client connects to the `owlry-core` daemon via Unix socket IPC. Start the daemon first: +For side-by-side testing against a production daemon, set `$OWLRY_SOCKET`: ```bash -# Start daemon (systemd recommended) -systemctl --user enable --now owlry-core.service - -# Or run directly -owlry-core - -# Then launch UI -owlry # Launch with all providers -owlry -m app # Applications only -owlry -m cmd # PATH commands only -owlry --profile dev # Use a named config profile -owlry -m calc # Calculator plugin only (if installed) +OWLRY_SOCKET=/tmp/owlry-dev.sock target/release/owlry -d & +OWLRY_SOCKET=/tmp/owlry-dev.sock target/release/owlry -m uuctl +pkill -f 'target/release/owlry -d' ``` -### dmenu Mode +`$OWLRY_SOCKET` overrides `$XDG_RUNTIME_DIR/owlry/owlry.sock` for both the daemon (bind path) and the UI client (connect path). -dmenu mode runs locally without the daemon. Use `-m dmenu` with piped input for interactive selection. The selected item is printed to stdout (not executed), so pipe the output to execute it: - -```bash -# Screenshot menu (execute selected command) -printf '%s\n' \ - "grimblast --notify copy screen" \ - "grimblast --notify copy area" \ - "grimblast --notify edit screen" \ - | owlry -m dmenu -p "Screenshot" \ - | sh - -# Git branch checkout -git branch | owlry -m dmenu -p "checkout" | xargs git checkout - -# Kill a process -ps -eo comm | sort -u | owlry -m dmenu -p "kill" | xargs pkill - -# Select and open a project -find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code -``` - -### CLI Flags - -| Flag | Description | -|------|-------------| -| `-m`, `--mode MODE` | Start in single-provider mode (app, cmd, dmenu, calc, etc.) | -| `--profile NAME` | Use a named profile from config (defines which modes to enable) | -| `-p`, `--prompt TEXT` | Custom prompt text for the search input (dmenu mode) | - -### Available Modes - -| Mode | Description | -|------|-------------| -| `app` | Desktop applications | -| `cmd` | PATH commands | -| `dmenu` | Pipe-based selection (requires stdin, runs locally) | -| `calc` | Calculator (plugin) | -| `clip` | Clipboard history (plugin) | -| `emoji` | Emoji picker (plugin) | -| `ssh` | SSH hosts (plugin) | -| `sys` | System actions (plugin) | -| `bm` | Bookmarks (plugin) | -| `file` | File search (plugin) | -| `web` | Web search (plugin) | -| `uuctl` | systemd user units (plugin) | - -### Search Prefixes - -Type these in the search box to filter by provider: - -| Prefix | Provider | Example | -|--------|----------|---------| -| `:app` | Applications | `:app firefox` | -| `:cmd` | PATH commands | `:cmd git` | -| `:sys` | System actions | `:sys shutdown` | -| `:ssh` | SSH hosts | `:ssh server` | -| `:clip` | Clipboard | `:clip password` | -| `:bm` | Bookmarks | `:bm github` | -| `:emoji` | Emoji | `:emoji heart` | -| `:calc` | Calculator | `:calc sqrt(16)` | -| `:web` | Web search | `:web rust docs` | -| `:file` | Files | `:file config` | -| `:uuctl` | systemd | `:uuctl docker` | -| `:tag:X` | Filter by tag | `:tag:development` | - -### Trigger Prefixes - -| Trigger | Provider | Example | -|---------|----------|---------| -| `=` | Calculator | `= 5+3` | -| `?` | Web search | `? rust programming` | -| `/` | File search | `/ .bashrc` | - -### Keyboard Shortcuts - -| Key | Action | -|-----|--------| -| `Enter` | Launch selected item | -| `Escape` | Close launcher / exit submenu | -| `Up` / `Down` | Navigate results | -| `Tab` | Cycle filter tabs | -| `Shift+Tab` | Cycle tabs (reverse) | -| `Ctrl+1..9` | Toggle tab by position | - -### Plugin CLI - -```bash -owlry plugin list # List installed -owlry plugin list --available # Show registry -owlry plugin search "query" # Search registry -owlry plugin install # Install from registry -owlry plugin install ./path # Install from local path -owlry plugin remove # Uninstall -owlry plugin enable/disable # Toggle -owlry plugin create # Create Lua plugin template -owlry plugin create -r rune # Create Rune plugin template -owlry plugin validate ./path # Validate plugin structure -owlry plugin run [args] # Run plugin CLI command -owlry plugin commands # List plugin commands -owlry plugin runtimes # Show available runtimes -``` - -## Release Workflow - -Always use `just` for releases - do NOT manually edit Cargo.toml for version bumps: - -```bash -# Bump a single crate -just bump-crate owlry-core 0.5.1 - -# Bump all crates to same version -just bump-all 0.5.1 - -# Bump core UI only -just bump 0.5.1 - -# Create and push release tag -git push && just tag - -# Tagging convention: every crate gets its own tag -# Format: {crate-name}-v{version} -# Examples: -# owlry-v1.0.1 -# owlry-core-v1.1.0 -# owlry-lua-v1.1.0 -# owlry-rune-v1.1.0 -# plugin-api-v1.0.1 -# -# The plugins repo uses the same convention: -# owlry-plugin-bookmarks-v1.0.1 -# owlry-plugin-calculator-v1.0.1 -# etc. -# -# IMPORTANT: After bumping versions, tag EVERY changed crate individually. -# The plugin-api tag is referenced by owlry-plugins Cargo.toml as a git dependency. - -# AUR package management -just aur-update # Update core UI PKGBUILD -just aur-update-pkg NAME # Update specific package (owlry-core, owlry-lua, etc.) -just aur-update-all # Update all AUR packages -just aur-publish # Publish core UI to AUR -just aur-publish-all # Publish all AUR packages - -# Version inspection -just show-versions # List all crate versions -just aur-status # Show AUR package versions and git status -``` - -## AUR Packaging - -The `aur/` directory contains PKGBUILDs for core packages: - -| Category | Packages | -|----------|----------| -| Core UI | `owlry` | -| Core Daemon | `owlry-core` | -| Runtimes | `owlry-lua`, `owlry-rune` | -| Meta-bundles | `owlry-meta-essentials`, `owlry-meta-widgets`, `owlry-meta-tools`, `owlry-meta-full` | - -Plugin AUR packages are in the separate `owlry-plugins` repo at `somegit.dev/Owlibou/owlry-plugins`. - -## Architecture - -### Client/Daemon Split - -Owlry uses a client/daemon architecture: - -- **`owlry`** (client): GTK4 UI that connects to the daemon via Unix socket IPC. Handles rendering, user input, and launching applications. In dmenu mode, runs a local `ProviderManager` directly (no daemon needed). -- **`owlry-core`** (daemon): Headless background service that loads plugins, manages providers, handles fuzzy matching, frecency scoring, and serves queries over IPC. Runs as a systemd user service. - -### Workspace Structure +## CLI shape ``` -owlry/ -├── Cargo.toml # Workspace root -├── systemd/ # systemd user service/socket files -│ ├── owlry-core.service -│ └── owlry-core.socket -├── crates/ -│ ├── owlry/ # UI client binary (GTK4 + Layer Shell) -│ │ └── src/ -│ │ ├── main.rs # Entry point -│ │ ├── app.rs # GTK Application setup, CSS loading -│ │ ├── cli.rs # Clap CLI argument parsing -│ │ ├── client.rs # CoreClient - IPC client to daemon -│ │ ├── backend.rs # SearchBackend - abstraction over IPC/local -│ │ ├── theme.rs # Theme loading -│ │ ├── plugin_commands.rs # Plugin CLI subcommand handlers -│ │ ├── providers/ # dmenu provider (local-only) -│ │ └── ui/ # GTK widgets (MainWindow, ResultRow, submenu) -│ ├── owlry-core/ # Daemon library + binary -│ │ └── src/ -│ │ ├── main.rs # Daemon entry point -│ │ ├── lib.rs # Public API (re-exports modules) -│ │ ├── server.rs # Unix socket IPC server -│ │ ├── ipc.rs # Request/Response message types -│ │ ├── filter.rs # ProviderFilter - mode/prefix filtering -│ │ ├── paths.rs # XDG path utilities, socket path -│ │ ├── notify.rs # Desktop notifications -│ │ ├── config/ # Config loading (config.toml) -│ │ ├── data/ # FrecencyStore -│ │ ├── providers/ # Application, Command, native/lua provider hosts -│ │ └── plugins/ # Plugin loading, manifests, registry, runtimes -│ ├── owlry-plugin-api/ # ABI-stable plugin interface -│ ├── owlry-lua/ # Lua script runtime (cdylib) -│ └── owlry-rune/ # Rune script runtime (cdylib) +owlry UI, auto mode (default) +owlry -m UI, single-provider mode +owlry --profile UI, named profile from config +owlry -p custom prompt text + +owlry daemon run the daemon (alias: -d) +owlry dmenu [-p ] dmenu mode (reads stdin, prints selection) +owlry doctor config + socket + providers status +owlry providers [] list providers (or show one) +owlry config validate parse config, report errors +owlry config show print resolved effective config +owlry migrate-config TOML → init.lua (stub; lands with Lua config) ``` -### IPC Protocol +The `owlry plugin ...` subcommand tree from 1.x is **gone** in 2.0. Nothing to install, manage, or run via the CLI. -Communication uses newline-delimited JSON over a Unix domain socket at `$XDG_RUNTIME_DIR/owlry/owlry.sock`. +## Provider model -**Request types** (`owlry_core::ipc::Request`): +Two traits in `src/providers/mod.rs`: -| Type | Purpose | -|------|---------| -| `Query` | Search with text and optional mode filters | -| `Launch` | Record a launch event for frecency | -| `Providers` | List available providers | -| `Refresh` | Refresh a specific provider | -| `Toggle` | Toggle visibility (client-side concern, daemon acks) | -| `Submenu` | Query submenu actions for a plugin item | -| `PluginAction` | Execute a plugin action command | +- **`Provider`** — static providers (apps, commands, power, bookmarks, clipboard, emoji, ssh, systemd). Populate `self.items` in `refresh()`, return `&self.items` from `items()`. Optional: `prefix()`, `icon()`, `tab_label()`, `search_noun()`, `position()`, `priority()`, `submenu_actions(data)`, `execute_action(command)`. +- **`DynamicProvider`** — per-keystroke providers (calculator, converter, filesearch, websearch). Generate items in `query(text)`. No `refresh`/`items` cache. -**Response types** (`owlry_core::ipc::Response`): +`ProviderManager::new_with_config` is the canonical registration site. Each provider is gated by both a cargo feature (compile-time) and a config flag in `[providers]` (runtime). -| Type | Purpose | -|------|---------| -| `Results` | Search results with `Vec` | -| `Providers` | Provider list with `Vec` (includes optional `tab_label`, `search_noun`) | -| `SubmenuItems` | Submenu actions for a plugin | -| `Ack` | Success acknowledgement | -| `Error` | Error with message | +## Submenu protocol -### Core Data Flow +A provider returns items whose `command` field looks like `SUBMENU::`. When the user selects one, the UI parses out `(plugin_id, data)`, sends a `Request::Submenu { plugin_id, data }`, and the daemon routes that to `Provider::submenu_actions(data)` on the matching provider. The systemd provider uses this for service start/stop/restart/enable/disable/status/journal actions. -``` -[owlry UI] [owlry-core daemon] +For now the protocol is string-encoded; a typed redesign is on the roadmap. -main.rs → CliArgs → OwlryApp main.rs → Server::bind() - ↓ ↓ - SearchBackend UnixListener accept loop - ↓ ↓ - ┌──────┴──────┐ handle_request() - ↓ ↓ ↓ -Daemon Local (dmenu) ┌───────────┴───────────┐ - ↓ ↓ ↓ -CoreClient ──── IPC ────→ ProviderManager ProviderFilter - ↓ ↓ - [Provider impls] parse_query() - ↓ - LaunchItem[] - ↓ - FrecencyStore (boost) - ↓ - Response::Results ──── IPC ────→ UI rendering -``` +## v2 naming rules -### Provider System +| Old (pre-v2) | New (2.0+) | Notes | +|---|---|---| +| `owlryd` binary | `owlry -d` | Single binary | +| `owlryd.service` / `owlryd.socket` | `owlry.service` / `owlry.socket` | `.install` hook handles upgrade | +| `:sys` / `:system` / "System" provider | `:power` / "Power" provider | `:sys` and `:system` kept as aliases | +| `providers.system = true` (config) | `providers.power = true` | Old key still accepted via serde alias | +| `badge_sys` (theme color) | `badge_power` | Old key aliased; CSS var `--owlry-badge-sys` still emitted for transition | +| `Plugin("sys")` type_id | `Plugin("power")` type_id | CLI mode parsing maps all three to `power` | -**Core providers** (in `owlry-core`): -- **Application**: Desktop applications from XDG directories -- **Command**: Shell commands from PATH +## Frecency -**dmenu provider** (in `owlry` client, local only): -- **Dmenu**: Pipe-based input (dmenu compatibility) +`~/.local/share/owlry/frecency.json`. Auto-saved every 5 min by the daemon; flushed on SIGTERM/SIGINT/SIGHUP. Boost weight via `providers.frecency_weight` (0.0 disabled, 1.0 strong). -All other providers are native plugins in the separate `owlry-plugins` repo (`somegit.dev/Owlibou/owlry-plugins`). +## When you need to verify behavior live -**User plugins** (script-based, in `~/.config/owlry/plugins/`): -- **Lua plugins**: Loaded by `owlry-lua` runtime from `/usr/lib/owlry/runtimes/liblua.so` -- **Rune plugins**: Loaded by `owlry-rune` runtime from `/usr/lib/owlry/runtimes/librune.so` -- User plugins are **hot-reloaded** automatically when files change (no daemon restart needed) -- Custom prefixes (e.g., `:hs`) are resolved dynamically for user plugins +1. `cargo build --release --features full` +2. `OWLRY_SOCKET=/tmp/owlry-dev.sock target/release/owlry -d &` +3. Run smoke queries via socket OR the UI with `OWLRY_SOCKET` set +4. `pkill -f 'target/release/owlry -d'` -`ProviderManager` (in `owlry-core`) orchestrates providers and handles: -- Fuzzy matching via `SkimMatcherV2` -- Frecency score boosting -- Native plugin loading from `/usr/lib/owlry/plugins/` -- Script runtime loading from `/usr/lib/owlry/runtimes/` for user plugins -- Filesystem watching for automatic user plugin hot-reload - -**Submenu System**: Plugins can return items with `SUBMENU:plugin_id:data` commands. When selected, the plugin is queried with `?SUBMENU:data` to get action items (e.g., systemd service actions). - -**Provider Display Metadata**: Script plugins can declare `tab_label` and `search_noun` in their `plugin.toml` `[[providers]]` section, or at runtime via `owlry.provider.register()` (Lua) / `register_provider()` (Rune). These flow through the `Provider` trait → `ProviderDescriptor` → IPC `ProviderDesc` → UI. The UI resolves metadata via `provider_meta::resolve()`, checking IPC metadata first, then falling back to the hardcoded match table. Unknown plugins auto-generate labels from `type_id` (e.g., "hs" → "Hs"). Native plugins use the hardcoded match table since `owlry-plugin-api::ProviderInfo` doesn't include these fields (deferred to API v5). - -### Plugin API - -Native plugins use the ABI-stable interface in `owlry-plugin-api`: - -```rust -#[repr(C)] -pub struct PluginVTable { - pub info: extern "C" fn() -> PluginInfo, - pub providers: extern "C" fn() -> RVec, - pub provider_init: extern "C" fn(id: RStr) -> ProviderHandle, - pub provider_refresh: extern "C" fn(ProviderHandle) -> RVec, - pub provider_query: extern "C" fn(ProviderHandle, RStr) -> RVec, - pub provider_drop: extern "C" fn(ProviderHandle), -} - -// Each plugin exports: -#[no_mangle] -pub extern "C" fn owlry_plugin_vtable() -> &'static PluginVTable -``` - -Plugins are compiled as `.so` (cdylib) and loaded by the daemon at startup. - -**Plugin locations** (when deployed): -- `/usr/lib/owlry/plugins/*.so` - Native plugins -- `/usr/lib/owlry/runtimes/*.so` - Script runtimes (liblua.so, librune.so) -- `~/.config/owlry/plugins/` - User plugins (Lua/Rune) - -### Filter & Prefix System - -`ProviderFilter` (`owlry-core/src/filter.rs`) handles: -- CLI mode selection (`--mode app`) -- Profile-based mode selection (`--profile dev`) -- Provider toggling (Ctrl+1/2/3) -- Prefix parsing (`:app`, `:cmd`, `:sys`, etc.) -- Dynamic prefix fallback for user plugins (any `:word` prefix maps to `Plugin(word)`) - -Query parsing extracts prefix and forwards clean query to providers. - -### SearchBackend - -`SearchBackend` (`owlry/src/backend.rs`) abstracts over two modes: -- **`Daemon`**: Wraps `CoreClient`, sends queries over IPC to `owlry-core` -- **`Local`**: Wraps `ProviderManager` directly (used for dmenu mode only) - -### UI Layer - -- `MainWindow` (`src/ui/main_window.rs`): GTK4 window with Layer Shell overlay -- `ResultRow` (`src/ui/result_row.rs`): Individual result rendering -- `submenu` (`src/ui/submenu.rs`): Universal submenu parsing utilities (plugins provide actions) - -### Configuration - -`Config` (`owlry-core/src/config/mod.rs`) loads from `~/.config/owlry/config.toml`: -- Auto-detects terminal (`$TERMINAL` -> `xdg-terminal-exec` -> common terminals) -- Optional `use_uwsm = true` for systemd session integration (launches apps via `uwsm app --`) -- Profiles: Define named mode sets under `[profiles.]` with `modes = ["app", "cmd", ...]` - -### Theming - -CSS loading priority (`owlry/src/app.rs`): -1. Base structural CSS (`resources/base.css`) -2. Theme CSS (built-in "owl" or custom `~/.config/owlry/themes/{name}.css`) -3. User overrides (`~/.config/owlry/style.css`) -4. Config variable injection - -### Systemd Integration - -Service files in `systemd/`: -- `owlry-core.service`: Runs daemon as `Type=simple`, restarts on failure -- `owlry-core.socket`: Socket activation at `%t/owlry/owlry.sock` - -Start with: `systemctl --user enable --now owlry-core.service` - -## Plugins - -Plugins live in a separate repository: `somegit.dev/Owlibou/owlry-plugins` - -13 native plugin crates, all compiled as cdylib (.so): - -| Category | Plugins | Behavior | -|----------|---------|----------| -| Static | bookmarks, clipboard, emoji, scripts, ssh, system, systemd | Loaded at startup, refresh() populates items | -| Dynamic | calculator, websearch, filesearch | Queried per-keystroke via query() | -| Widget | weather, media, pomodoro | Displayed at top of results | - -## Key Patterns - -- **Rc>** used throughout for GTK signal handlers needing mutable state -- **Feature flag `dev-logging`**: Wraps debug!() calls in `#[cfg(feature = "dev-logging")]` -- **Feature flag `lua`**: Enables built-in Lua runtime (off by default); enable to embed Lua in core binary -- **Script runtimes**: External `.so` runtimes loaded from `/usr/lib/owlry/runtimes/` — Lua and Rune user plugins loaded from `~/.config/owlry/plugins/` -- **Hot-reload**: Filesystem watcher (`notify` crate) monitors user plugins dir and reloads runtimes on file changes -- **dmenu mode**: Runs locally without daemon. Use `-m dmenu` with piped stdin -- **Frecency**: Time-decayed frequency scoring stored in `~/.local/share/owlry/frecency.json` -- **ABI stability**: Plugin interface uses `abi_stable` crate for safe Rust dynamic linking -- **Plugin API v3**: Adds `position` (Normal/Widget) and `priority` fields to ProviderInfo -- **ProviderType simplification**: Core uses only `Application`, `Command`, `Dmenu`, `Plugin(String)` - all plugin-specific types removed from core -- **Provider display metadata**: Tab labels and search nouns flow from plugin.toml → `Provider` trait → IPC `ProviderDesc` → UI `provider_meta::resolve()`. Known native plugins use a hardcoded match table; script plugins declare metadata in their manifest or at runtime registration - -## Dependencies (Rust 1.90+, GTK 4.12+) - -External tool dependencies (for plugins): -- Clipboard plugin: `cliphist`, `wl-clipboard` -- File search plugin: `fd` or `mlocate` -- Emoji plugin: `wl-clipboard`, `noto-fonts-emoji` -- Systemd plugin: `systemd` (user services) -- Bookmarks plugin: Firefox support uses `rusqlite` with bundled SQLite (no system dependency) +Don't fight the prod daemon for the default socket path during testing. The env var exists for exactly this. diff --git a/README.md b/README.md index 20a461d..4b362ca 100644 --- a/README.md +++ b/README.md @@ -6,158 +6,120 @@ [![GTK4](https://img.shields.io/badge/GTK-4.12-green.svg)](https://gtk.org/) [![Wayland](https://img.shields.io/badge/Wayland-native-blueviolet.svg)](https://wayland.freedesktop.org/) -A lightweight, owl-themed application launcher for Wayland, built with GTK4 and Layer Shell. +A lightweight, owl-themed application launcher for Wayland, built with GTK4 and Layer Shell. Single-binary, configurable, fast. + +> **2.0 highlights.** Owlry collapsed from 15 AUR packages and a dynamic plugin system into one binary. All providers (apps, commands, calculator, converter, power, bookmarks, clipboard, emoji, ssh, systemd, websearch, filesearch) are compiled in and gated by cargo features. The AUR build ships everything; `cargo install` consumers can pick a subset. See [`docs/RESTRUCTURE-V2.md`](docs/RESTRUCTURE-V2.md) for the full rewrite story. ## Features -- **Client/daemon architecture** — Instant window appearance, providers stay loaded in memory -- **Built-in providers** — Calculator, unit/currency converter, and system actions out of the box -- **Built-in settings editor** — Configure everything from within the launcher (`:config`) -- **11 optional plugins** — Clipboard, emoji, weather, media, bookmarks, and more -- **Widget providers** — Weather, media controls, and pomodoro timer at the top of results -- **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags +- **Single binary** — UI client, daemon, and providers in one `/usr/bin/owlry` +- **Client/daemon architecture** — Daemon (`owlry -d`) keeps providers warm; UI appears instantly +- **Built-in providers** — Apps, PATH commands, calculator, unit/currency converter, power actions +- **Optional providers** (compiled in via `--features full` on AUR) — Bookmarks (Firefox + Chromium), clipboard history, emoji, SSH hosts, systemd user units, web search, filesystem search +- **Fuzzy search with tags** — Fast matching across names, descriptions, category tags - **Config profiles** — Named mode presets for different workflows -- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:config`, `:tag:X`, etc. +- **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:power`, `:uuctl`, `:tag:X`, etc. - **Frecency ranking** — Frequently/recently used items rank higher - **Toggle behavior** — Bind one key to open/close the launcher -- **GTK4 theming** — System theme by default, with 10 built-in themes +- **GTK4 theming** — System theme by default, 10 built-in themes shipped - **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 +- **dmenu compatible** — Pipe-based selection, no daemon required ## Installation ### Arch Linux (AUR) ```bash -# Core (includes calculator, converter, system actions, settings editor) -yay -S owlry - -# Add individual plugins as needed -yay -S owlry-plugin-bookmarks owlry-plugin-weather owlry-plugin-clipboard - -# For custom Lua/Rune user plugins -yay -S owlry-lua # Lua 5.4 runtime -yay -S owlry-rune # Rune runtime +paru -S owlry # or yay -S owlry ``` -### Available Packages - -**Core packages** (this repo): - -| Package | Description | -|---------|-------------| -| `owlry` | GTK4 UI client | -| `owlry-core` | Daemon (`owlryd`) with built-in calculator, converter, system, and settings providers | -| `owlry-lua` | Lua 5.4 script runtime for user plugins | -| `owlry-rune` | Rune script runtime for user plugins | - -**Plugin packages** ([owlry-plugins](https://somegit.dev/Owlibou/owlry-plugins) repo): - -| Package | Description | -|---------|-------------| -| `owlry-plugin-bookmarks` | Firefox, Chrome, Brave, Edge bookmarks | -| `owlry-plugin-clipboard` | History via cliphist | -| `owlry-plugin-emoji` | 400+ searchable emoji | -| `owlry-plugin-filesearch` | File search (`/ filename`) | -| `owlry-plugin-media` | MPRIS media controls | -| `owlry-plugin-pomodoro` | Pomodoro timer widget | -| `owlry-plugin-scripts` | User scripts | -| `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` | -| `owlry-plugin-systemd` | User services with actions | -| `owlry-plugin-weather` | Weather widget | -| `owlry-plugin-websearch` | Web search (`? query`) | - -> **Note:** Calculator, converter, and system actions are built into `owlry-core` and do not require separate packages. +Upgrading from 1.x: paru/pacman transparently swaps the old `owlry-core`, `owlry-lua`, `owlry-rune`, and every `owlry-plugin-*` and `owlry-meta-*` package for the new unified `owlry`. The `.install` hook prints a banner with the systemd unit rename instructions (`owlryd.{service,socket}` → `owlry.{service,socket}`). ### Build from Source -**Dependencies:** +**System dependencies:** + ```bash -# Arch Linux +# Arch sudo pacman -S gtk4 gtk4-layer-shell -# Ubuntu/Debian +# Ubuntu / Debian sudo apt install libgtk-4-dev libgtk4-layer-shell-dev # Fedora sudo dnf install gtk4-devel gtk4-layer-shell-devel ``` -**Build (requires Rust 1.90+):** +**Rust 1.90+:** + ```bash git clone https://somegit.dev/Owlibou/owlry.git cd owlry - -# Build daemon + UI -cargo build --release -p owlry -p owlry-core - -# Build runtimes (for user plugins) -cargo build --release -p owlry-lua -p owlry-rune - -# Build everything in this workspace -cargo build --release --workspace +cargo build --release --features full # full = all providers (matches the AUR build) +just install-local # installs binary + systemd units (sudo) ``` -**Plugins** are in a [separate repo](https://somegit.dev/Owlibou/owlry-plugins): +Cargo features (pick a subset if you don't need everything): + +| Feature | Provider | Default? | +|---|---|---| +| `app` | XDG desktop applications | yes | +| `cmd` | Executables on `$PATH` | yes | +| `calc` | Calculator | yes | +| `conv` | Unit & currency converter | yes | +| `power` | Shutdown/reboot/lock | yes | +| `dmenu` | Pipe-based selection | yes | +| `bookmarks` | Firefox + Chromium bookmarks (rusqlite) | opt-in | +| `clipboard` | Clipboard history (`cliphist`) | opt-in | +| `emoji` | Emoji picker (`wl-clipboard`) | opt-in | +| `ssh` | SSH hosts from `~/.ssh/config` | opt-in | +| `systemd` | systemd user units (type_id: `uuctl`) | opt-in | +| `websearch` | Web search (DuckDuckGo / configurable) | opt-in | +| `filesearch` | `fd` / `mlocate` shellout | opt-in | +| `full` | All of the above | — | + ```bash -git clone https://somegit.dev/Owlibou/owlry-plugins.git -cd owlry-plugins -cargo build --release -p owlry-plugin-bookmarks # or any plugin +cargo build --release --no-default-features --features "app,cmd,calc,conv,power,dmenu,systemd" ``` -**Install locally:** -```bash -just install-local -``` - -This installs the UI (`owlry`), daemon (`owlryd`), runtimes, and systemd service files. - ## Getting Started -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 -Choose one of three methods: +Three options: -**1. Compositor autostart (recommended for most users)** - -Add to your compositor config: +**1. Systemd user service (recommended)** ```bash -# Hyprland (~/.config/hypr/hyprland.conf) -exec-once = owlryd - -# Sway (~/.config/sway/config) -exec owlryd +systemctl --user enable --now owlry.service ``` -**2. Systemd user service** +Reload config from disk without restarting: ```bash -systemctl --user enable --now owlryd.service +systemctl --user reload owlry.service # or: kill -HUP $(pidof owlry) ``` -The daemon reloads its configuration on `SIGHUP` without restarting — useful when editing `config.toml` directly: +**2. Socket activation** ```bash -systemctl --user reload owlryd.service -# or: kill -HUP $(pidof owlryd) +systemctl --user enable owlry.socket ``` -**3. Socket activation (auto-start on first use)** +Daemon starts the first time the UI connects. + +**3. Compositor autostart** -```bash -systemctl --user enable owlryd.socket ``` +# Hyprland +exec-once = owlry -d -The daemon starts automatically when the UI client first connects. +# Sway +exec owlry -d +``` ### Launching the UI -Bind `owlry` to a key in your compositor: - ```bash # Hyprland bind = SUPER, Space, exec, owlry @@ -166,58 +128,42 @@ bind = SUPER, Space, exec, owlry bindsym $mod+space exec owlry ``` -Running `owlry` a second time while it is already open sends a toggle command — the window closes. 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. +Running `owlry` while a window is already open sends a toggle command — single keybind acts as open/close. If the daemon isn't running, the UI tries to start it via systemd. ## Usage -```bash -owlry # Launch with all providers -owlry -m app # Applications only -owlry -m cmd # PATH commands only -owlry -m calc # Calculator only -owlry --profile dev # Use a named profile from config -owlry --help # Show all options with examples +``` +owlry launch UI, auto mode +owlry -m auto launch UI, auto mode (explicit alias) +owlry -m launch UI in single-provider mode +owlry --profile launch UI with a named profile + +owlry -d run the daemon (alias: `owlry daemon`) +owlry dmenu [-p ] dmenu mode (reads stdin, prints selection) +owlry doctor diagnostics: config + socket + providers +owlry providers [] list providers (or show details for one) +owlry config validate parse config, report errors +owlry config show print the resolved effective config as TOML +owlry migrate-config TOML → init.lua (stub in 2.0; lands in a later 2.x release) ``` ### Profiles -Profiles are named sets of modes defined in your config: - ```toml [profiles.dev] modes = ["app", "cmd", "ssh"] [profiles.media] -modes = ["media", "emoji"] - -[profiles.minimal] -modes = ["app"] +modes = ["emoji", "clipboard"] ``` -Launch with a profile: - ```bash owlry --profile dev ``` -You can bind different profiles to different keys: - -```bash -# Hyprland -bind = SUPER, Space, exec, owlry -bind = SUPER, D, exec, owlry --profile dev -bind = SUPER, M, exec, owlry --profile media -``` - -Profiles can also be managed from the launcher itself — see [Settings Editor](#settings-editor). - ### dmenu Mode -Owlry is dmenu-compatible. Pipe input for interactive selection — the selected item is printed to stdout (not executed), so you pipe the output to execute it. - -dmenu mode is self-contained: it does not use the daemon and works without `owlryd` running. +`owlry dmenu` (or the legacy `owlry -m dmenu`) reads stdin and prints the selection to stdout. It runs locally — no daemon required. ```bash # Screenshot menu @@ -225,24 +171,22 @@ printf '%s\n' \ "grimblast --notify copy screen" \ "grimblast --notify copy area" \ "grimblast --notify edit screen" \ - | owlry -m dmenu -p "Screenshot" \ + | owlry dmenu -p "Screenshot" \ | sh # Git branch checkout -git branch | owlry -m dmenu -p "checkout" | xargs git checkout +git branch | owlry dmenu -p "checkout" | xargs git checkout # Kill a process -ps -eo comm | sort -u | owlry -m dmenu -p "kill" | xargs pkill +ps -eo comm | sort -u | owlry dmenu -p "kill" | xargs pkill -# Select and open a project -find ~/projects -maxdepth 1 -type d | owlry -m dmenu | xargs code +# Open a project +find ~/projects -maxdepth 1 -type d | owlry dmenu | xargs code -# Package manager search -pacman -Ssq | owlry -m dmenu -p "install" | xargs sudo pacman -S +# Package manager +pacman -Ssq | owlry dmenu -p "install" | xargs sudo pacman -S ``` -The `-p` / `--prompt` flag sets a custom label for the search input. - ### Keyboard Shortcuts | Key | Action | @@ -254,56 +198,32 @@ The `-p` / `--prompt` flag sets a custom label for the search input. | `Shift+Tab` | Cycle filter tabs (reverse) | | `Ctrl+1..9` | Toggle tab by position | -### Settings Editor - -Type `:config` to browse and modify settings without editing files: - -| Command | What it does | -|---------|-------------| -| `:config` | Show all setting categories | -| `:config providers` | Toggle built-in providers on/off (calculator, converter, system, frecency) | -| `: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. - -> **Note:** `:config providers` only covers built-in providers. To enable or disable plugins, use `owlry plugin enable/disable ` or set `disabled_plugins` in `[plugins]`. - ### Search Prefixes | Prefix | Provider | Example | |--------|----------|---------| | `:app` | Applications | `:app firefox` | | `:cmd` | PATH commands | `:cmd git` | -| `:sys` | System actions | `:sys shutdown` | -| `:ssh` | SSH hosts | `:ssh server` | -| `:clip` | Clipboard | `:clip password` | -| `:bm` | Bookmarks | `:bm github` | -| `:emoji` | Emoji | `:emoji heart` | -| `:script` | Scripts | `:script backup` | -| `:file` | Files | `:file config` | +| `:power` (`:sys`, `:system`) | Power & session actions | `:power shutdown` | | `:calc` | Calculator | `:calc sqrt(16)` | +| `:conv` | Converter | `:conv 5 ft to m` | +| `:bm` | Bookmarks | `:bm github` | +| `:clip` | Clipboard | `:clip password` | +| `:emoji` | Emoji | `:emoji heart` | +| `:ssh` | SSH hosts | `:ssh server` | +| `:uuctl` (`:systemd`) | systemd user units | `:uuctl dbus` | | `:web` | Web search | `:web rust docs` | -| `:uuctl` | systemd | `:uuctl docker` | -| `:config` | Settings | `:config theme` | -| `:tag:X` | Filter by tag | `:tag:development` | +| `:file` | Files | `:file config` | +| `:tag:X` | Filter all results by tag | `:tag:development` | ### Trigger Prefixes | Trigger | Provider | Example | |---------|----------|---------| | `=` | Calculator | `= 5+3` | -| `calc ` | Calculator | `calc sqrt(16)` | | `>` | Converter | `> 20 km to mi` | | `?` | Web search | `? rust programming` | -| `web ` | Web search | `web linux tips` | | `/` | File search | `/ .bashrc` | -| `find ` | File search | `find config` | ## Configuration @@ -314,184 +234,74 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free | `~/.config/owlry/config.toml` | Main configuration | | `~/.config/owlry/themes/*.css` | Custom themes | | `~/.config/owlry/style.css` | CSS overrides | -| `~/.config/owlry/plugins/` | User plugins (Lua/Rune) | -| `~/.local/share/owlry/scripts/` | User scripts | | `~/.local/share/owlry/frecency.json` | Usage history | - -System locations: - -| Path | Purpose | -|------|---------| -| `/usr/lib/owlry/plugins/*.so` | Installed native plugins | -| `/usr/lib/owlry/runtimes/*.so` | Lua/Rune script runtimes | +| `$XDG_RUNTIME_DIR/owlry/owlry.sock` | IPC socket (overridable via `$OWLRY_SOCKET`) | | `/usr/share/doc/owlry/config.example.toml` | Example configuration | +| `/usr/share/owlry/themes/` | Bundled themes | ### Quick Start ```bash -# Copy example config mkdir -p ~/.config/owlry cp /usr/share/doc/owlry/config.example.toml ~/.config/owlry/config.toml +$EDITOR ~/.config/owlry/config.toml +owlry config validate ``` -Or configure from within the launcher: type `:config` to interactively change settings. - ### Example Configuration ```toml [general] show_icons = true max_results = 100 -tabs = ["app", "cmd", "uuctl"] # Provider tabs shown in the header bar -# terminal_command = "kitty" # Auto-detected; overrides $TERMINAL and xdg-terminal-exec -# use_uwsm = false # Enable for systemd session integration (uwsm app --) +tabs = ["app", "cmd", "uuctl"] # tabs shown in the header bar +# terminal_command = "kitty" # auto-detected; overrides $TERMINAL and xdg-terminal-exec +# use_uwsm = false # enable for systemd session integration (uwsm app --) [appearance] width = 850 height = 650 font_size = 14 border_radius = 12 -# theme = "owl" # Or: catppuccin-mocha, nord, dracula, etc. (see Theming section) +# theme = "owl" # or: catppuccin-mocha, nord, dracula, ... (see Theming) -# Optional per-element color overrides — all fields are optional, unset inherits from theme +# Optional per-element color overrides. All fields are optional; unset inherits from the theme. # [appearance.colors] # background = "#1e1e2e" -# background_secondary = "#313244" -# border = "#45475a" -# text = "#cdd6f4" # accent = "#cba6f7" -# badge_app = "#a6e3a1" # All badge_* keys: app, cmd, clip, ssh, emoji, file, -# badge_web = "#89dceb" # script, sys, uuctl, web, calc, bm, dmenu, -# badge_media = "#f38ba8" # media, weather, pomo +# badge_app = "#a6e3a1" # badge_* keys: app, cmd, clip, ssh, emoji, file, +# badge_web = "#89dceb" # power (alias: badge_sys), uuctl, web, calc, bm, dmenu [providers] -applications = true # .desktop files -commands = true # PATH executables -calculator = true # Built-in math expressions (= or calc trigger) -converter = true # Built-in unit/currency converter (> trigger) -system = true # Built-in shutdown/reboot/lock actions -frecency = true # Boost frequently used items -frecency_weight = 0.3 # 0.0 = disabled, 1.0 = strong boost +applications = true # .desktop files +commands = true # PATH executables +calculator = true # `=` or :calc +converter = true # `>` or :conv +power = true # `:power` shutdown/reboot/lock (alias: system) +systemd = true # `:uuctl` user units (alias: uuctl) +bookmarks = true # Firefox + Chromium +clipboard = true # via cliphist +emoji = true # picker via wl-clipboard +ssh = true # ~/.ssh/config hosts +websearch = true # `?` or :web +filesearch = true # `/` or :file +frecency = true # boost frequently used items +frecency_weight = 0.3 # 0.0 disabled .. 1.0 strong # Web search engine: google, duckduckgo, bing, startpage, searxng, brave, ecosia # Or a custom URL with a {query} placeholder: "https://example.com/search?q={query}" search_engine = "duckduckgo" -[plugins] -# disabled_plugins = ["emoji", "pomodoro"] # Plugin IDs to disable -# registry_url = "https://..." # Custom plugin registry URL - -# Sandboxing for Lua/Rune user plugins (~/.config/owlry/plugins/) -# [plugins.sandbox] -# allow_filesystem = false # Allow access outside plugin directory -# allow_network = false # Allow outbound network requests -# allow_commands = false # Allow shell command execution -# memory_limit = 67108864 # Lua memory cap in bytes (default: 64 MB) - -# Per-plugin config (for plugins that expose configurable options) -# [plugin_config.my-plugin] -# option = "value" - -# Profiles: named sets of modes +# Profiles — named mode sets [profiles.dev] modes = ["app", "cmd", "ssh"] - -[profiles.media] -modes = ["media", "emoji"] +[profiles.minimal] +modes = ["app"] ``` -See `/usr/share/doc/owlry/config.example.toml` for all options with documentation. +See `/usr/share/doc/owlry/config.example.toml` for every option with documentation. -## Plugin System - -Owlry uses a modular plugin architecture. Plugins are loaded by the daemon from: - -- `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages) -- `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`) - -### Disabling Plugins - -Add plugin IDs to the disabled list in your config: - -```toml -[plugins] -disabled_plugins = ["emoji", "pomodoro"] -``` - -Or use the CLI: - -```bash -owlry plugin disable emoji -owlry plugin enable emoji -``` - -> **Note:** `:config providers` in the launcher only manages built-in providers (calculator, converter, system). Use `disabled_plugins` or `owlry plugin disable` for plugins. - -### Plugin Management CLI - -```bash -# List installed plugins (shows both native .so plugins and user script plugins) -owlry plugin list -owlry plugin list --enabled # Only enabled -owlry plugin list --available # Show registry plugins - -# Search registry -owlry plugin search "weather" - -# Install/remove -owlry plugin install # From registry -owlry plugin install ./my-plugin # From local path -owlry plugin remove - -# Enable/disable -owlry plugin enable -owlry plugin disable - -# Plugin info -owlry plugin info -owlry plugin commands # List plugin CLI commands - -# Create new plugin -owlry plugin create my-plugin # Lua (default) -owlry plugin create my-plugin -r rune # Rune - -# Run plugin command -owlry plugin run [args...] -``` - -### Plugin Display Metadata - -Plugins can declare how they appear in the UI via their `plugin.toml`. Without these fields, the tab label and search placeholder default to a capitalized version of the `type_id`. - -```toml -[[providers]] -id = "my-plugin" -name = "My Plugin" -type_id = "myplugin" -tab_label = "My Plugin" # Tab button and page title -search_noun = "plugin items" # Search placeholder: "Search plugin items..." -``` - -Both fields are optional. If omitted, the UI auto-generates from `type_id` (e.g., `type_id = "hs"` shows "Hs" as the tab label). - -Lua plugins can also set these at runtime via `owlry.provider.register()`: - -```lua -owlry.provider.register({ - id = "my-plugin", - name = "My Plugin", - tab_label = "My Plugin", - search_noun = "plugin items", -}) -``` - -### Creating Custom Plugins - -See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for: -- Native plugin development (Rust) -- Lua plugin development -- Rune plugin development -- Available APIs +`owlry config show` prints the resolved effective config (defaults merged with your file). `owlry config validate` parses it and reports errors. ## Theming @@ -515,8 +325,6 @@ See [docs/PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for: theme = "catppuccin-mocha" ``` -Or select interactively: type `:config theme` in the launcher. - ### Custom Theme Create `~/.config/owlry/themes/mytheme.css`: @@ -548,29 +356,32 @@ Create `~/.config/owlry/themes/mytheme.css`: ## Architecture -Owlry uses a client/daemon split: - ``` -owlryd (daemon) owlry (GTK4 UI client) -├── Loads config + plugins ├── Connects to daemon via Unix socket -├── Built-in providers ├── Renders results in GTK4 window -│ ├── Applications (.desktop) ├── Handles keyboard input -│ ├── Commands (PATH) ├── Toggle: second launch closes window -│ ├── Calculator (math) └── dmenu mode (self-contained, no daemon) -│ ├── Converter (units/currency) -│ ├── System (power/session) -│ └── Config editor (settings) -├── Plugin loader -│ ├── /usr/lib/owlry/plugins/*.so -│ ├── /usr/lib/owlry/runtimes/ -│ └── ~/.config/owlry/plugins/ -├── Frecency tracking (auto-saved every 5 min; flushed on shutdown) -└── IPC server (Unix socket) - │ - └── $XDG_RUNTIME_DIR/owlry/owlry.sock +owlry (single binary) +├── default invocation GTK4 UI client (connects to daemon over socket) +├── owlry -d / owlry daemon IPC daemon (loads providers, listens on the socket) +├── owlry dmenu stdin → selection (no daemon) +└── owlry doctor / providers / config diagnostics & config tools + +Daemon: +├── Built-in providers applications, commands, power, calculator, converter +├── Optional providers bookmarks, clipboard, emoji, ssh, systemd, websearch, filesearch +│ (compiled in per cargo feature) +├── Frecency tracking auto-saved every 5 min; flushed on SIGTERM/SIGINT +└── IPC server $XDG_RUNTIME_DIR/owlry/owlry.sock (newline-delimited JSON) ``` -The daemon keeps providers and plugins loaded in memory, so the UI appears instantly when launched. The UI client is a thin GTK4 layer that sends queries and receives results over the socket. +The daemon keeps providers and items warm in memory; the UI launches instantly because there's no work to do at startup. The UI client is a thin GTK4 layer that streams queries and renders results. + +Set `OWLRY_SOCKET=/path/to/sock` to override the socket location — useful for running a development daemon alongside a production one. + +## Roadmap + +See [ROADMAP.md](ROADMAP.md) for feature ideas and [docs/RESTRUCTURE-V2.md](docs/RESTRUCTURE-V2.md) for the v2 rewrite story. + +Headline upcoming work: +- **Lua-driven configuration** (2.1 / 3.0) — `~/.config/owlry/init.lua` replaces TOML. User-defined providers via `owlry.provider {}` in the same file (Hyprland-style configs-as-code). `owlry migrate-config` lands at the same time. +- **Widget providers return** — weather, MPRIS media controls, pomodoro timer. Deferred from 2.0 while the UI positioning is reworked. ## License @@ -580,5 +391,6 @@ GNU General Public License v3.0 — see [LICENSE](LICENSE). - [GTK4](https://gtk.org/) — UI toolkit - [gtk4-layer-shell](https://github.com/wmww/gtk4-layer-shell) — Wayland Layer Shell -- [abi_stable](https://crates.io/crates/abi_stable) — ABI-stable Rust plugins - [fuzzy-matcher](https://crates.io/crates/fuzzy-matcher) — Fuzzy search +- [rusqlite](https://crates.io/crates/rusqlite) — Bundled SQLite for bookmarks +- [expr-solver-lib](https://crates.io/crates/expr-solver-lib) — Calculator backend diff --git a/ROADMAP.md b/ROADMAP.md index e6f9118..6929e33 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,12 +1,31 @@ # Owlry Roadmap -Feature ideas and future development plans for Owlry. +Feature ideas and future development plans. For the v2 rewrite story (where 14 packages and the dynamic plugin system went), see [docs/RESTRUCTURE-V2.md](docs/RESTRUCTURE-V2.md). + +## Locked-in for an upcoming 2.x release + +### Lua-driven configuration (Phase 3) +Replace `config.toml` with `~/.config/owlry/init.lua`. The config is real Lua, evaluated at startup via embedded `mlua` (Lua 5.4). User-defined providers, keybindings, and theme overrides all live in the same file — Hyprland-style configs-as-code. Ships with `owlry migrate-config` (TOML → init.lua) and hot-reload on save. + +```lua +local owlry = require("owlry") + +owlry.set { theme = "owl", width = 850, tabs = { "app", "cmd", "uuctl" } } +owlry.providers { "app", "cmd", "power", "bookmarks", "systemd" } + +owlry.provider { + id = "hs", prefix = ":hs", tab_label = "Shutdown", + items = function() return { { name = "Lock", command = "hyprlock" } } end, +} +``` + +### Widget providers return +Weather, MPRIS media controls, and pomodoro timer were deferred from 2.0 while the widget-row UI is redesigned. They'll come back as a feature group once the placement model is settled. ([D20 in the v2 plan](docs/RESTRUCTURE-V2.md).) + +--- ## High Value, Low Effort -### Plugin hot-reload -Detect `.so` file changes in `/usr/lib/owlry/plugins/` and reload without restarting the launcher. The loader infrastructure already exists. - ### Frecency pruning Add `max_entries` and `max_age_days` config options. Prune old entries on startup to prevent `frecency.json` from growing unbounded. @@ -14,7 +33,10 @@ Add `max_entries` and `max_age_days` config options. Prune old entries on startu Show last N launched items. Data already exists in frecency.json — just needs a provider to surface it. ### Clipboard images -`cliphist` supports images. Extend the clipboard plugin to show image thumbnails in results. +`cliphist` supports images. Extend the clipboard provider to show image thumbnails in results. + +### Dynamic providers in `owlry doctor` +Today `doctor` lists static providers only. Surface the dynamic ones (calc, conv, websearch, filesearch) too. --- @@ -31,23 +53,23 @@ Generalize the submenu system beyond systemd. Every result type gets contextual | Bookmarks | Open, Copy URL, Open incognito | | Clipboard | Paste, Delete from history | -This is the difference between a launcher and a command palette. - -### Plugin settings UI -A `:settings` provider that lists installed plugins and their configurable options. Edit values inline, writes to `config.toml`. +This is the difference between a launcher and a command palette. The `Provider::submenu_actions()` trait method (added in 2.0 for the systemd provider) is the foundation. ### Result action capture -Calculator shows `= 5+3 → 8`. Allow pressing Tab or Ctrl+C to copy the result to clipboard instead of "launching" it. Useful for calculator, file paths, URLs. +Calculator shows `= 5+3 → 8`. Allow pressing Tab or Ctrl+C to copy the result to clipboard instead of launching. Useful for calculator output, file paths, URLs. + +### Split the 1000+ LOC files +`providers/mod.rs`, `ui/main_window.rs`, `providers/converter/units.rs` are all over a thousand lines each. Carve them into focused modules to make code review and onboarding less painful. (Phase 5 hygiene in the v2 plan.) --- ## Bigger Bets ### Window switcher with live thumbnails -A `windows` plugin using Wayland screencopy to show live thumbnails of open windows. Hyprland and Sway expose window lists via IPC. Could replace Alt+Tab. +A `windows` provider using Wayland screencopy to show live thumbnails of open windows. Hyprland and Sway expose window lists via IPC. Could replace Alt+Tab. ### Cross-device bookmark sync -Firefox and Chrome sync bookmarks across devices. Parse sync metadata to show "recently added on other devices" or "bookmarks from phone". +Firefox and Chrome sync bookmarks across devices. Parse sync metadata to show "recently added on other devices". ### Natural language commands Parse simple natural language into system commands: @@ -58,52 +80,26 @@ Parse simple natural language into system commands: "volume 50%" → wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.5 ``` -Local pattern matching, no AI/cloud required. +Local pattern matching, no cloud required. -### Plugin marketplace -A curated registry of third-party Lua/Rune plugins with one-command install: - -```bash -owlry plugin install github-notifications -owlry plugin install todoist -owlry plugin install spotify-controls -``` - -The script runtimes make this viable without recompiling. +### Drop the daemon +After Lua config lands and we profile startup honestly: if the daemon's keep-providers-warm justification doesn't hold up against an mmap'd cache, collapse to a single-process model. Eliminates the socket protocol, IPC types, and most of `client.rs` + `server.rs`. ([D17 deferred this decision past 2.0.](docs/RESTRUCTURE-V2.md)) --- ## Technical Debt -### Split monorepo for user build efficiency -Currently, a small core fix requires all 16 AUR packages to rebuild (same source tarball). Split into 3 repos: +### `expr-solver-lib` evaluation +The calculator's `expr-solver-lib` dep is small and old. If it stagnates further or becomes unsupported, switch to `evalexpr` v13+ which is actively maintained. -| Repo | Contents | Versioning | -|------|----------|------------| -| `owlry` | Core binary | Independent | -| `owlry-plugin-api` | ABI interface (crates.io) | Semver, conservative | -| `owlry-plugins` | 13 plugins + 2 runtimes | Independent per plugin | +### Submenu protocol redesign +The `SUBMENU::` string-encoded command is a workable hack. A typed IPC variant would be cleaner — keep the surface in `Request::Submenu` but stop overloading the `command` field. (Phase 5 hygiene.) -**Execution order:** -1. Publish `owlry-plugin-api` to crates.io -2. Update monorepo to use crates.io dependency -3. Create `owlry-plugins` repo, move plugins + runtimes -4. Slim current repo to core-only -5. Update AUR PKGBUILDs with new source URLs - -**Benefit:** Core bugfix = 1 rebuild. Plugin fix = 1 rebuild. Third-party plugins possible via crates.io. - -### Replace meval with evalexpr -`meval` depends on `nom v1.2.4` which will be rejected by future Rust versions. Migrate calculator plugin and Lua runtime to `evalexpr` v13+. - -### Plugin API backwards compatibility -When `API_VERSION` increments, provide a compatibility shim so v3 plugins work with v4 core. Prevents ecosystem fragmentation. - -### Per-plugin configuration -Current flat `[providers]` config doesn't scale. Design a `[plugins.weather]`, `[plugins.pomodoro]` structure that plugins can declare and the core validates. +### Double-daemon spawn +Pre-2.0, the systemd unit and a Hyprland `exec-once = owlryd` could both spawn a daemon. The 2.0 socket-activation path (`owlry.socket`) eliminates the need for an explicit autostart — verify nothing else still launches the daemon directly. (Phase 5 hygiene.) --- ## Priority -If we had to pick one: **Actions on any result**. It transforms every provider from "search and launch" to "search and do anything". The ROI is massive. +If we had to pick one for the next release: **Actions on any result**. It transforms every provider from "search and launch" to "search and do anything". The ROI is massive and the trait foundation is already in 2.0. diff --git a/aur/owlry/.SRCINFO b/aur/owlry/.SRCINFO index 7223204..c8bd311 100644 --- a/aur/owlry/.SRCINFO +++ b/aur/owlry/.SRCINFO @@ -21,13 +21,16 @@ pkgbase = owlry provides = owlry-plugin-clipboard provides = owlry-plugin-emoji provides = owlry-plugin-filesearch - provides = owlry-plugin-media - provides = owlry-plugin-pomodoro - provides = owlry-plugin-scripts provides = owlry-plugin-ssh provides = owlry-plugin-systemd - provides = owlry-plugin-weather provides = owlry-plugin-websearch + provides = owlry-plugin-media + provides = owlry-plugin-pomodoro + provides = owlry-plugin-weather + provides = owlry-plugin-scripts + provides = owlry-plugin-calculator + provides = owlry-plugin-converter + provides = owlry-plugin-system provides = owlry-meta-essentials provides = owlry-meta-widgets provides = owlry-meta-tools @@ -39,13 +42,16 @@ pkgbase = owlry conflicts = owlry-plugin-clipboard conflicts = owlry-plugin-emoji conflicts = owlry-plugin-filesearch - conflicts = owlry-plugin-media - conflicts = owlry-plugin-pomodoro - conflicts = owlry-plugin-scripts conflicts = owlry-plugin-ssh conflicts = owlry-plugin-systemd - conflicts = owlry-plugin-weather conflicts = owlry-plugin-websearch + conflicts = owlry-plugin-media + conflicts = owlry-plugin-pomodoro + conflicts = owlry-plugin-weather + conflicts = owlry-plugin-scripts + conflicts = owlry-plugin-calculator + conflicts = owlry-plugin-converter + conflicts = owlry-plugin-system conflicts = owlry-meta-essentials conflicts = owlry-meta-widgets conflicts = owlry-meta-tools @@ -57,13 +63,16 @@ pkgbase = owlry replaces = owlry-plugin-clipboard replaces = owlry-plugin-emoji replaces = owlry-plugin-filesearch - replaces = owlry-plugin-media - replaces = owlry-plugin-pomodoro - replaces = owlry-plugin-scripts replaces = owlry-plugin-ssh replaces = owlry-plugin-systemd - replaces = owlry-plugin-weather replaces = owlry-plugin-websearch + replaces = owlry-plugin-media + replaces = owlry-plugin-pomodoro + replaces = owlry-plugin-weather + replaces = owlry-plugin-scripts + replaces = owlry-plugin-calculator + replaces = owlry-plugin-converter + replaces = owlry-plugin-system replaces = owlry-meta-essentials replaces = owlry-meta-widgets replaces = owlry-meta-tools diff --git a/aur/owlry/PKGBUILD b/aur/owlry/PKGBUILD index dbe5231..dd1669f 100644 --- a/aur/owlry/PKGBUILD +++ b/aur/owlry/PKGBUILD @@ -34,17 +34,28 @@ _v2_replaced=( 'owlry-core' 'owlry-lua' 'owlry-rune' + # Plugins folded into owlry as feature-gated modules. 'owlry-plugin-bookmarks' 'owlry-plugin-clipboard' 'owlry-plugin-emoji' 'owlry-plugin-filesearch' - 'owlry-plugin-media' - 'owlry-plugin-pomodoro' - 'owlry-plugin-scripts' 'owlry-plugin-ssh' 'owlry-plugin-systemd' - 'owlry-plugin-weather' 'owlry-plugin-websearch' + # Deferred providers (D20); package replaced so users get a clean + # transition. Functionality returns in a later 2.x release. + 'owlry-plugin-media' + 'owlry-plugin-pomodoro' + 'owlry-plugin-weather' + # Replaced by user Lua config (D12), Phase 3+. + 'owlry-plugin-scripts' + # Pre-v2 transitional stubs (pkgrel -99) where calc/conv/power were + # already folded into owlry-core. Listed so any straggler installs + # are swept up by the v2 upgrade. + 'owlry-plugin-calculator' + 'owlry-plugin-converter' + 'owlry-plugin-system' + # Meta-bundles superseded by the single owlry package. 'owlry-meta-essentials' 'owlry-meta-widgets' 'owlry-meta-tools' From 1487a12c658852f78e93f917914f3a51757c98e4 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 02:57:46 +0200 Subject: [PATCH 18/23] feat(man): ship owlry(1) man page Hand-written groff man page documenting the v2 CLI surface, environment variables, files, and examples. Sections: NAME / SYNOPSIS / DESCRIPTION OPTIONS -d, -m, --profile, -p, -h, -V COMMANDS daemon, dmenu, doctor, providers, config, migrate-config ENVIRONMENT XDG_RUNTIME_DIR, OWLRY_SOCKET, XDG_CONFIG_HOME, XDG_DATA_HOME, TERMINAL FILES config, themes, frecency, socket, docs, units EXAMPLES UI launch, daemon background, dmenu pipeline, doctor, side-by-side dev daemon via OWLRY_SOCKET SEE ALSO / BUGS / AUTHORS aur/owlry/PKGBUILD: install -Dm644 data/owlry.1 -> /usr/share/man/man1/owlry.1. makepkg auto-gzips to owlry.1.gz in the final package. Verified with groff -Tutf8 -man: no warnings or errors. Tested locally via makepkg; man page lands at /usr/share/man/man1/owlry.1.gz in the 2.7 MB .pkg.tar.zst. --- aur/owlry/PKGBUILD | 3 + data/owlry.1 | 193 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 data/owlry.1 diff --git a/aur/owlry/PKGBUILD b/aur/owlry/PKGBUILD index dd1669f..7f477b4 100644 --- a/aur/owlry/PKGBUILD +++ b/aur/owlry/PKGBUILD @@ -108,6 +108,9 @@ package() { install -Dm644 data/config.example.toml "$pkgdir/usr/share/doc/$pkgname/config.example.toml" install -Dm644 data/style.example.css "$pkgdir/usr/share/doc/$pkgname/style.example.css" + # Man page. + install -Dm644 data/owlry.1 "$pkgdir/usr/share/man/man1/owlry.1" + # Themes. install -d "$pkgdir/usr/share/$pkgname/themes" install -Dm644 data/themes/*.css "$pkgdir/usr/share/$pkgname/themes/" diff --git a/data/owlry.1 b/data/owlry.1 new file mode 100644 index 0000000..a1a914c --- /dev/null +++ b/data/owlry.1 @@ -0,0 +1,193 @@ +.TH OWLRY 1 "2026-05-13" "owlry 2.0.0" "User Commands" +.SH NAME +owlry \- Wayland application launcher with daemon and built-in providers +.SH SYNOPSIS +.B owlry +[\fIOPTIONS\fR] [\fICOMMAND\fR] +.SH DESCRIPTION +.B owlry +is a lightweight Wayland application launcher built with GTK4 and Layer Shell. +It runs as a single binary that hosts both the GTK4 UI client and the IPC +daemon, and ships every provider compiled in. + +Invoked with no arguments, +.B owlry +launches the UI client in auto mode (results from all enabled providers). +Subcommands run the daemon, dmenu pipeline, diagnostics, or config tools. +.SH OPTIONS +.TP +.B \-d, \-\-daemon +Run the IPC daemon instead of the UI. Equivalent to the +.B daemon +subcommand. Conflicts with +.BR \-m ", " \-\-profile ", " \-p . +.TP +.BI \-m " MODE\fR, " \-\-mode " MODE" +Start the UI in single-provider mode. Core modes: +.BR app ", " cmd ", " dmenu ", " auto . +Built-in modes: +.BR calc ", " conv ", " power . +Optional modes (depend on cargo features at build time): +.BR bm ", " clip ", " emoji ", " ssh ", " systemd ", " uuctl ", " web ", " file . +.TP +.BI \-\-profile " NAME" +Launch the UI with a named profile. Profiles are defined under +.B [profiles.] +in +.IR ~/.config/owlry/config.toml . +.TP +.BI \-p " TEXT\fR, " \-\-prompt " TEXT" +Custom prompt text for the search input. Most useful in dmenu mode. +.TP +.B \-h, \-\-help +Print help with examples. +.TP +.B \-V, \-\-version +Print the version. +.SH COMMANDS +.TP +.B daemon +Run the IPC daemon. Binds the socket and serves queries until killed. +Equivalent to +.BR "owlry \-d" . +.TP +.BR "dmenu " [\fB\-p\fR\ \fIPROMPT\fR] +Read items from standard input, present them in the launcher UI, print the +selection to standard output. Runs locally; the daemon is not required. +.TP +.B doctor +Print diagnostics: config status, daemon socket reachability, and the list of +registered providers. Non-destructive. +.TP +.BR "providers " [\fIID\fR] +List all registered providers, or print details (icon, prefix, position, tab +label, search noun) for one. Requires a running daemon. +.TP +.B "config validate" +Parse the config file and report errors. Exits 0 on success, 1 on parse failure. +.TP +.B "config show" +Print the resolved effective configuration (defaults merged with the user file) +as TOML. +.TP +.B migrate-config +Migrate TOML configuration to a future +.B init.lua +format. Stub in 2.0; functional once the Lua config layer lands in a later 2.x +release. See +.IR docs/RESTRUCTURE-V2.md . +.SH ENVIRONMENT +.TP +.B XDG_RUNTIME_DIR +Used to derive the default IPC socket path +.RI ( $XDG_RUNTIME_DIR/owlry/owlry.sock ). +.TP +.B OWLRY_SOCKET +Full path override for the IPC socket. When set, the daemon binds and the UI +client connects to this exact path, bypassing +.BR XDG_RUNTIME_DIR . +Useful for running a development daemon alongside a production instance. +.TP +.B XDG_CONFIG_HOME +Config root. Defaults to +.IR ~/.config . +.TP +.B XDG_DATA_HOME +Frecency data root. Defaults to +.IR ~/.local/share . +.TP +.B TERMINAL +Auto-detected terminal for items marked as needing a terminal. Overridden by +.B general.terminal_command +in the config. +.SH FILES +.TP +.I ~/.config/owlry/config.toml +Main configuration. See +.B owlry config show +for the effective state. +.TP +.I ~/.config/owlry/themes/ +User CSS themes (referenced from +.BR appearance.theme ). +.TP +.I ~/.config/owlry/style.css +CSS overrides applied last in the cascade. +.TP +.I ~/.local/share/owlry/frecency.json +Frecency state — auto-saved every 5 minutes and on SIGTERM/SIGINT. +.TP +.I $XDG_RUNTIME_DIR/owlry/owlry.sock +IPC Unix socket. See +.B OWLRY_SOCKET +to override. +.TP +.I /usr/share/doc/owlry/config.example.toml +Annotated example configuration. +.TP +.I /usr/share/owlry/themes/ +Bundled themes. +.TP +.I /usr/lib/systemd/user/owlry.service +systemd user service. Enable with +.BR "systemctl \-\-user enable \-\-now owlry.service" . +.SH EXAMPLES +.PP +Launch the UI (auto mode): +.PP +.RS +.EX +owlry +.EE +.RE +.PP +Run the daemon explicitly (background): +.PP +.RS +.EX +owlry \-d & +.EE +.RE +.PP +dmenu-style script pipeline: +.PP +.RS +.EX +git branch | owlry dmenu \-p "checkout" | xargs git checkout +.EE +.RE +.PP +Diagnose an install: +.PP +.RS +.EX +owlry doctor +.EE +.RE +.PP +Run a development daemon on a separate socket: +.PP +.RS +.EX +OWLRY_SOCKET=/tmp/owlry-dev.sock owlry \-d & +OWLRY_SOCKET=/tmp/owlry-dev.sock owlry providers +.EE +.RE +.SH SEE ALSO +.BR systemctl (1), +.BR systemd.unit (5), +.BR cliphist (1), +.BR wl-copy (1), +.BR fd (1) +.PP +Project homepage: +.UR https://somegit.dev/Owlibou/owlry +.UE +.SH BUGS +Report issues at +.UR https://somegit.dev/Owlibou/owlry/issues +.UE +.SH AUTHORS +Owlibou — see +.I /usr/share/doc/owlry/README.md +for acknowledgments. From fa3f04e3fc337fbe9d9b2e46f5cdb9eabcc05674 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 03:00:24 +0200 Subject: [PATCH 19/23] build(justfile): rewrite for single-repo, single-package v2 reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-v2 justfile drove a 5-crate workspace and 15 AUR packages, with machinery to bump-all crates, iterate over aur/*/ subdirs, manage meta- package versions, and run a release-crate pipeline parameterised by crate name. None of that applies anymore — there is one crate and one PKGBUILD. Replaced with: - build / release / release-minimal — release defaults to --features full so dev builds match the AUR binary's feature set. - run / run-daemon / run-debug — run-debug uses dev-logging feature. - test — runs both feature axes (no-default-features and --features full) so contributors can't silently break either build. - check — cargo check on both axes + clippy on --features full. - install-local — installs the man page too now. - version / bump — single-crate operations, no 'crate' parameter. - tag / push-tags — tags owlry-v directly. - aur-stage / aur-update / aur-publish / aur-status / aur-commit — all hardcoded to aur/owlry/, no pkg parameter. - aur-update fetches the tagged tarball, recomputes b2sum, regenerates .SRCINFO. Errors with a clear message if the tag isn't pushed yet. - aur-publish errors with a clone hint if aur/owlry/.git is missing. - release-owlry — full pipeline: bump -> push -> tag -> push-tags -> aur-update -> aur-commit -> push -> aur-publish. Drop-in replacement for the old release-crate recipe. Removed: - build-ui / build-daemon / release-daemon — no separate daemon crate. - show-versions / crate-version / bump-crate / bump-all — single crate. - tag-crate — there's no per-crate concept anymore. - aur-update-pkg / aur-update-all / aur-publish-pkg / aur-publish-all / aur-test-pkg-by-name / release-crate — collapsed since only aur/owlry exists. - bump-meta — meta-bundles dropped in the v2 collapse. aur-local-test now defaults its args to 'owlry' so 'just aur-local-test' without arguments does the right thing. --- justfile | 328 +++++++++++++++++++++---------------------------------- 1 file changed, 125 insertions(+), 203 deletions(-) diff --git a/justfile b/justfile index 772b582..563cfe1 100644 --- a/justfile +++ b/justfile @@ -1,4 +1,7 @@ -# Owlry build and release automation +# Owlry build and release automation. +# +# v2 collapsed the workspace from 5 crates to 1 and shipped a single AUR +# package. The multi-crate / multi-package machinery has been removed. default: @just --list @@ -8,138 +11,128 @@ default: build: cargo build --workspace +# Release build with the AUR feature set (every optional provider compiled in). release: + cargo build --workspace --release --features full + +# Release build with only the minimal default features. +release-minimal: cargo build --workspace --release # === Run === +# Launch the UI (auto mode, default features). run *ARGS: cargo run -p owlry -- {{ARGS}} +# Run the daemon in the foreground (alias: `cargo run -- -d`). run-daemon *ARGS: cargo run -p owlry -- -d {{ARGS}} +# Run with dev-logging feature enabled (verbose debug output). +run-debug *ARGS: + cargo run -p owlry --features dev-logging -- {{ARGS}} + # === Quality === +# Run the full test matrix used in CI: no-default-features then --features full. test: - cargo test --workspace + cargo test --workspace --no-default-features + cargo test --workspace --features full +# cargo check + clippy across both feature axes. check: - cargo check --workspace - cargo clippy --workspace + cargo check --workspace --no-default-features + cargo check --workspace --features full + cargo clippy --workspace --features full fmt: cargo fmt --all +fmt-check: + cargo fmt --all --check + clean: cargo clean -# === Install === +# === Install (local dev) === install-local: #!/usr/bin/env bash set -euo pipefail - echo "Building release..." - cargo build -p owlry --release + echo "Building release with --features full..." + cargo build -p owlry --release --features full echo "Installing binary..." sudo install -Dm755 target/release/owlry /usr/bin/owlry - echo "Installing systemd service files..." + echo "Installing systemd user units..." sudo install -Dm644 systemd/owlry.service /usr/lib/systemd/user/owlry.service - sudo install -Dm644 systemd/owlry.socket /usr/lib/systemd/user/owlry.socket + sudo install -Dm644 systemd/owlry.socket /usr/lib/systemd/user/owlry.socket + echo "Installing man page..." + sudo install -Dm644 data/owlry.1 /usr/share/man/man1/owlry.1 + + echo echo "Done. Start daemon: systemctl --user enable --now owlry.service" # === Version Management === -show-versions: - #!/usr/bin/env bash - echo "=== Crate Versions ===" - for toml in crates/*/Cargo.toml; do - name=$(grep '^name' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') - ver=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') - printf " %-30s %s\n" "$name" "$ver" - done +# Print the current owlry version. +version: + @grep '^version' crates/owlry/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/' -# Get version of a specific crate -crate-version crate: - @grep '^version' crates/{{crate}}/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/' - -# Bump a single crate version, update Cargo.lock, commit -bump-crate crate new_version: - #!/usr/bin/env bash - set -euo pipefail - toml="crates/{{crate}}/Cargo.toml" - [ -f "$toml" ] || { echo "Error: $toml not found"; exit 1; } - - old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') - [ "$old" = "{{new_version}}" ] && { echo "{{crate}} already at {{new_version}}"; exit 0; } - - echo "Bumping {{crate}} from $old to {{new_version}}" - sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml" - cargo check -p {{crate}} - git add "$toml" Cargo.lock - git commit -m "chore({{crate}}): bump version to {{new_version}}" - echo "{{crate}} bumped to {{new_version}}" - -# Bump all crates to same version -bump-all new_version: - #!/usr/bin/env bash - set -euo pipefail - for toml in crates/*/Cargo.toml; do - crate=$(basename $(dirname "$toml")) - old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') - [ "$old" = "{{new_version}}" ] && continue - echo "Bumping $crate from $old to {{new_version}}" - sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml" - done - cargo check --workspace - git add crates/*/Cargo.toml Cargo.lock - git commit -m "chore: bump all crates to {{new_version}}" - echo "All crates bumped to {{new_version}}" - -# Bump core UI only +# Bump the owlry version, update Cargo.lock, commit. bump new_version: - just bump-crate owlry {{new_version}} + #!/usr/bin/env bash + set -euo pipefail + toml="crates/owlry/Cargo.toml" + old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') + if [ "$old" = "{{new_version}}" ]; then + echo "owlry already at {{new_version}}" + exit 0 + fi + echo "Bumping owlry from $old to {{new_version}}" + sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml" + cargo check -p owlry + git add "$toml" Cargo.lock + git commit -m "chore(owlry): bump version to {{new_version}}" # === Tagging === -# Tag a specific crate (format: {crate}-v{version}) -tag-crate crate: +# Tag the current owlry version as owlry-v. +tag: #!/usr/bin/env bash set -euo pipefail - ver=$(grep '^version' "crates/{{crate}}/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/') - tag="{{crate}}-v$ver" + ver=$(just version) + tag="owlry-v$ver" if git rev-parse "$tag" >/dev/null 2>&1; then echo "Tag $tag already exists" exit 0 fi - git tag -a "$tag" -m "{{crate}} v$ver" + git tag -a "$tag" -m "owlry v$ver" echo "Created tag $tag" -# Push all local tags +# Push all local tags upstream. push-tags: git push --tags -# === AUR Package Management === +# === AUR === +# +# Only one AUR package after the v2 collapse: aur/owlry/. Its subdirectory +# has its own .git pointing at aur.archlinux.org — see aur-stage below. -# Stage AUR files into the main repo git index. -# AUR subdirs have their own .git (for aur.archlinux.org), which makes -# git treat them as embedded repos. Temporarily hide .git to stage files. -aur-stage pkg: +# Stage AUR files into the main repo index, working around the embedded .git +# that would otherwise make git treat aur/owlry/ as an embedded repo. +aur-stage: #!/usr/bin/env bash set -euo pipefail - dir="aur/{{pkg}}" - [ -d "$dir" ] || { echo "Error: $dir not found"; exit 1; } - - # Build list of files to stage + dir="aur/owlry" files=("$dir/PKGBUILD" "$dir/.SRCINFO") for f in "$dir"/*.install; do [ -f "$f" ] && files+=("$f") done - if [ -d "$dir/.git" ]; then mv "$dir/.git" "$dir/.git.bak" git add "${files[@]}" @@ -148,186 +141,115 @@ aur-stage pkg: git add "${files[@]}" fi -# Update a specific AUR package PKGBUILD with correct version + checksum -aur-update-pkg pkg: +# Refresh aur/owlry/PKGBUILD to point at the current Cargo.toml version's +# tagged source tarball (fetches the tarball to recompute the b2sum) and +# regenerates .SRCINFO. Requires the tag to be pushed beforehand. +aur-update: #!/usr/bin/env bash set -euo pipefail - aur_dir="aur/{{pkg}}" - [ -d "$aur_dir" ] || { echo "Error: $aur_dir not found"; exit 1; } - - # Determine version - case "{{pkg}}" in - owlry-meta-*) - ver=$(grep '^pkgver=' "$aur_dir/PKGBUILD" | sed 's/pkgver=//') - echo "Meta-package {{pkg}} at $ver (bump pkgrel manually if needed)" - (cd "$aur_dir" && makepkg --printsrcinfo > .SRCINFO) - exit 0 - ;; - *) - crate_dir="crates/{{pkg}}" - [ -d "$crate_dir" ] || { echo "Error: $crate_dir not found"; exit 1; } - ver=$(grep '^version' "$crate_dir/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/') - ;; - esac - - tag="{{pkg}}-v$ver" + aur_dir="aur/owlry" + ver=$(just version) + tag="owlry-v$ver" url="https://somegit.dev/Owlibou/owlry/archive/$tag.tar.gz" - echo "Updating {{pkg}} to $ver (tag: $tag)" + echo "Updating aur/owlry/PKGBUILD to $ver (tag: $tag)" sed -i "s/^pkgver=.*/pkgver=$ver/" "$aur_dir/PKGBUILD" sed -i 's/^pkgrel=.*/pkgrel=1/' "$aur_dir/PKGBUILD" - # Update checksum from the tagged tarball - if grep -q "^source=" "$aur_dir/PKGBUILD"; then - echo "Downloading tarball and computing checksum..." - hash=$(curl -sL "$url" | b2sum | cut -d' ' -f1) - if [ -z "$hash" ] || [ ${#hash} -lt 64 ]; then - echo "Error: failed to download or hash $url" - exit 1 - fi - sed -i "s|^b2sums=.*|b2sums=('$hash')|" "$aur_dir/PKGBUILD" + echo "Fetching tagged tarball and computing b2sum..." + hash=$(curl -fsL "$url" | b2sum | cut -d' ' -f1) + if [ -z "$hash" ] || [ ${#hash} -lt 64 ]; then + echo "Error: failed to download or hash $url" + echo " (Is the tag pushed? Run 'just push-tags' first.)" + exit 1 fi + sed -i "s|^b2sums=.*|b2sums=('$hash')|" "$aur_dir/PKGBUILD" (cd "$aur_dir" && makepkg --printsrcinfo > .SRCINFO) - echo "{{pkg}} PKGBUILD updated to $ver" + echo "aur/owlry/ PKGBUILD updated to $ver." -# Shortcut: update core UI AUR package -aur-update: - just aur-update-pkg owlry - -# Publish a specific AUR package to aur.archlinux.org -aur-publish-pkg pkg: +# Push aur/owlry/ to aur.archlinux.org (via the embedded .git remote). +aur-publish: #!/usr/bin/env bash set -euo pipefail - aur_dir="aur/{{pkg}}" - [ -d "$aur_dir/.git" ] || { echo "Error: $aur_dir has no AUR git repo"; exit 1; } - + aur_dir="aur/owlry" + [ -d "$aur_dir/.git" ] || { + echo "Error: $aur_dir has no AUR git repo. Clone it first:" + echo " cd $aur_dir && git init && git remote add origin ssh://aur@aur.archlinux.org/owlry.git" + exit 1 + } cd "$aur_dir" ver=$(grep '^pkgver=' PKGBUILD | sed 's/pkgver=//') git add -A git commit -m "Update to v$ver" || { echo "Nothing to commit"; exit 0; } git push origin master - echo "{{pkg}} v$ver published to AUR!" + echo "owlry v$ver published to AUR." -# Shortcut: publish core UI to AUR -aur-publish: - just aur-publish-pkg owlry - -# Update and publish ALL AUR packages -aur-update-all: - #!/usr/bin/env bash - set -euo pipefail - for dir in aur/*/; do - pkg=$(basename "$dir") - [ -f "$dir/PKGBUILD" ] || continue - echo "=== $pkg ===" - just aur-update-pkg "$pkg" - echo "" - done - echo "All updated. Run 'just aur-publish-all' to publish." - -aur-publish-all: - #!/usr/bin/env bash - set -euo pipefail - for dir in aur/*/; do - pkg=$(basename "$dir") - [ -d "$dir/.git" ] || continue - [ -f "$dir/PKGBUILD" ] || continue - echo "=== $pkg ===" - just aur-publish-pkg "$pkg" - echo "" - done - echo "All published!" - -# Show AUR package status +# Show the current AUR PKGBUILD version. aur-status: #!/usr/bin/env bash - echo "=== AUR Package Status ===" - for dir in aur/*/; do - pkg=$(basename "$dir") - [ -f "$dir/PKGBUILD" ] || continue - ver=$(grep '^pkgver=' "$dir/PKGBUILD" | sed 's/pkgver=//') - if [ -d "$dir/.git" ]; then - printf " ✓ %-30s %s\n" "$pkg" "$ver" - else - printf " ✗ %-30s %s (no AUR repo)\n" "$pkg" "$ver" - fi - done + set -euo pipefail + dir="aur/owlry" + ver=$(grep '^pkgver=' "$dir/PKGBUILD" | sed 's/pkgver=//') + rel=$(grep '^pkgrel=' "$dir/PKGBUILD" | sed 's/pkgrel=//') + if [ -d "$dir/.git" ]; then + printf " ✓ owlry %s-%s\n" "$ver" "$rel" + else + printf " ✗ owlry %s-%s (no embedded AUR .git — clone first)\n" "$ver" "$rel" + fi -# Commit AUR file changes to the main repo (handles embedded .git dirs) -aur-commit msg="chore(aur): update PKGBUILDs": +# Stage + commit PKGBUILD/.SRCINFO/.install changes into the main repo. +aur-commit msg="chore(aur): update PKGBUILD": #!/usr/bin/env bash set -euo pipefail - for dir in aur/*/; do - pkg=$(basename "$dir") - [ -f "$dir/PKGBUILD" ] || continue - just aur-stage "$pkg" - done + just aur-stage git diff --cached --quiet && { echo "No AUR changes to commit"; exit 0; } git commit -m "{{msg}}" -# === Release Workflows === +# === Release Workflow === -# Release a single crate: bump → push → tag → update AUR → publish AUR -release-crate crate new_version: +# Full release pipeline: bump → push → tag → aur-update → aur-commit → aur-publish. +# Stops between push and tag to give the tagged tarball time to materialise on +# somegit.dev before fetching it for b2sum. +release-owlry new_version: #!/usr/bin/env bash set -euo pipefail - just bump-crate {{crate}} {{new_version}} + just bump {{new_version}} git push - just tag-crate {{crate}} + just tag just push-tags echo "Waiting for tag to propagate..." sleep 3 - just aur-update-pkg {{crate}} - just aur-commit "chore(aur): update {{crate}} to {{new_version}}" + just aur-update + just aur-commit "chore(aur): update owlry to {{new_version}}" git push - just aur-publish-pkg {{crate}} - echo "" - echo "{{crate}} v{{new_version}} released and published to AUR!" - -# === Meta Package Management === - -# Bump meta-package versions -bump-meta new_version: - #!/usr/bin/env bash - set -euo pipefail - for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do - file="aur/$pkg/PKGBUILD" - old=$(grep '^pkgver=' "$file" | sed 's/pkgver=//') - if [ "$old" != "{{new_version}}" ]; then - echo "Bumping $pkg from $old to {{new_version}}" - sed -i 's/^pkgver=.*/pkgver={{new_version}}/' "$file" - (cd "aur/$pkg" && makepkg --printsrcinfo > .SRCINFO) - fi - done - echo "Meta-packages bumped to {{new_version}}" + just aur-publish + echo + echo "owlry v{{new_version}} released and published to AUR." # === Testing === -# Quick local build test (no chroot, uses host deps) -aur-test-pkg pkg: +# Quick local PKGBUILD build (no chroot, uses host deps). +aur-test-pkg: #!/usr/bin/env bash set -euo pipefail - cd "aur/{{pkg}}" - echo "Testing {{pkg}} PKGBUILD..." + cd aur/owlry + echo "Testing PKGBUILD via makepkg -sf..." makepkg -sf - echo "Package built successfully!" + echo "Package built successfully." ls -lh *.pkg.tar.zst -# Build AUR packages from the local working tree in a clean chroot. -# Packages current source (incl. uncommitted changes), patches PKGBUILD, -# builds in dep order, injects local artifacts, restores PKGBUILD on exit. +# Build aur/owlry/ from the local working tree inside a clean extra chroot. +# Patches the PKGBUILD source line to a working-tree tarball; restores 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: +# Requires sudo (extra-x86_64-build runs as root). See scripts/aur-local-test +# for the full implementation. +aur-local-test *args="owlry": #!/usr/bin/env bash set -euo pipefail ts=$(date +%Y%m%d-%H%M%S) From 7569b2d7f040ccc6b612fcf70a92ab6004e08488 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 03:06:39 +0200 Subject: [PATCH 20/23] fix(bookmarks): force libsqlite3-sys bundled via explicit feature gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chroot build of owlry-2.0.0 failed at link time with every sqlite3_* symbol undefined — libsqlite3-sys's build.rs was taking the 'build_linked' branch (which links to system libsqlite3) instead of 'build_bundled' (which compiles sqlite3.c statically). The host build worked only because /usr/lib/libsqlite3.so happens to be installed. The original Cargo.toml had: rusqlite = { version = "0.39", features = ["bundled"], optional = true } In theory this activates rusqlite/bundled which pulls libsqlite3-sys/bundled. In practice — apparently a Cargo resolver edge case with optional deps in edition 2024 — the bundled feature did not flow into the resolved feature graph in a clean chroot build. Fixed by declaring rusqlite without inline features and wiring the bundled flag explicitly in the bookmarks feature row: [dependencies] rusqlite = { version = "0.39", optional = true } [features] bookmarks = ["dep:rusqlite", "rusqlite/bundled"] Verified via 'cargo tree --features full -i libsqlite3-sys -e features': the libsqlite3-sys 'bundled' feature now traces back to owlry/full -> owlry/bookmarks -> rusqlite/bundled. --- crates/owlry/Cargo.toml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/owlry/Cargo.toml b/crates/owlry/Cargo.toml index a88033d..682efbb 100644 --- a/crates/owlry/Cargo.toml +++ b/crates/owlry/Cargo.toml @@ -60,8 +60,11 @@ signal-hook = "0.3" expr-solver-lib = "1" reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "blocking"] } -# Optional providers (gated by cargo features below) -rusqlite = { version = "0.39", features = ["bundled"], optional = true } +# Optional providers (gated by cargo features below). +# Features declared on the bookmarks feature row, not inline here, so the +# `bundled` flag propagates cleanly to libsqlite3-sys regardless of how +# downstream consumers compose features. +rusqlite = { version = "0.39", optional = true } # Async oneshot channel (background thread -> main loop) futures-channel = "0.3" @@ -81,7 +84,7 @@ dev-logging = [] # Optional providers (compiled in when enabled). # The AUR PKGBUILD builds with --features "full" to ship everything. -bookmarks = ["dep:rusqlite"] +bookmarks = ["dep:rusqlite", "rusqlite/bundled"] clipboard = [] emoji = [] filesearch = [] From a7af0e5d46fbbb7b61ad3eefc762024a9cbad202 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 03:13:16 +0200 Subject: [PATCH 21/23] =?UTF-8?q?feat(v2):=20defer=20bookmarks=20provider?= =?UTF-8?q?=20(D22)=20=E2=80=94=20drop=20rusqlite=20dep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bookmarks provider opened Firefox's places.sqlite directly via rusqlite, which pulled libsqlite3-sys with its bundled feature to compile sqlite3.c statically. In clean Arch chroots the bundled feature kept slipping out of the resolved feature graph and the chroot build failed at link time with every sqlite3_* symbol undefined. Inline features = ["bundled"] on the optional dep didn't help; neither did the explicit "rusqlite/bundled" feature wiring (commit 7569b2d). Rather than fight Cargo's resolver further, defer the bookmarks provider alongside the widgets per the same pattern (D20 -> D22). Returns in a later 2.x release with a pure-Rust reader: - Chromium's Bookmarks file is already JSON. - Firefox exposes JSON backups under ~/.mozilla/firefox// bookmarkbackups/.jsonlz4 — needs lz4 + JSON parse, no SQLite. Removed: - crates/owlry/src/providers/bookmarks.rs - bookmarks module + registration in providers/mod.rs - bookmarks feature + rusqlite dep in Cargo.toml - ProvidersConfig.bookmarks field (existing user configs that still have 'bookmarks = true' silently ignore it — serde default behavior) - :bm prefix from README's search-prefix table - bookmarks acknowledgement in README + ROADMAP roadmap section Cargo.lock loses rusqlite, libsqlite3-sys, fallible-iterator, fallible-streaming-iterator, foldhash, hashlink, rsqlite-vfs, sqlite-wasm-rs (-80 lines). PKGBUILD: owlry-plugin-bookmarks stays in replaces= (any user who has it installed still needs a clean upgrade), but moved from the 'folded' comment block to the 'deferred (D20+)' block. Docs: - README: bookmarks removed from optional-providers list, feature table, search-prefix table, config example. Added to upcoming roadmap section alongside widgets. - ROADMAP: 'Bookmarks return' subsection added next to 'Widget providers return'. - CLAUDE.md: provider tree updated. - docs/RESTRUCTURE-V2.md: new decision D22 with chroot-build rationale; task #6 plugin count 8 -> 7 -> 6. 245 tests pass with --features full (was 252; the 7 bookmarks unit tests went with the module). Build verified locally; chroot build should now succeed since libsqlite3-sys is no longer in the graph at all. --- CLAUDE.md | 1 - Cargo.lock | 81 +--- README.md | 7 +- ROADMAP.md | 3 + aur/owlry/.SRCINFO | 6 +- aur/owlry/PKGBUILD | 4 +- crates/owlry/Cargo.toml | 12 +- crates/owlry/src/config/mod.rs | 4 - crates/owlry/src/providers/bookmarks.rs | 469 ------------------------ crates/owlry/src/providers/mod.rs | 7 - docs/RESTRUCTURE-V2.md | 5 +- 11 files changed, 18 insertions(+), 581 deletions(-) delete mode 100644 crates/owlry/src/providers/bookmarks.rs diff --git a/CLAUDE.md b/CLAUDE.md index a7b4947..26cb6b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,6 @@ crates/owlry/ -- everything │ ├── converter/ (feature: conv) │ ├── power.rs (feature: power — pre-v2 name: sys/system) │ ├── dmenu.rs (feature: dmenu) -│ ├── bookmarks.rs (feature: bookmarks) │ ├── clipboard.rs (feature: clipboard) │ ├── emoji.rs (feature: emoji) │ ├── ssh.rs (feature: ssh) diff --git a/Cargo.lock b/Cargo.lock index d855876..e975510 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -580,18 +580,6 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - [[package]] name = "fastrand" version = "2.3.0" @@ -620,12 +608,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "foreign-types" version = "0.3.2" @@ -1210,7 +1192,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash 0.1.5", + "foldhash", ] [[package]] @@ -1218,18 +1200,6 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "foldhash 0.2.0", -] - -[[package]] -name = "hashlink" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" -dependencies = [ - "hashbrown 0.16.1", -] [[package]] name = "heck" @@ -1587,17 +1557,6 @@ dependencies = [ "libc", ] -[[package]] -name = "libsqlite3-sys" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1882,7 +1841,6 @@ dependencies = [ "log", "notify-rust", "reqwest", - "rusqlite", "serde", "serde_json", "signal-hook", @@ -2129,31 +2087,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "rsqlite-vfs" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" -dependencies = [ - "hashbrown 0.16.1", - "thiserror 2.0.18", -] - -[[package]] -name = "rusqlite" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" -dependencies = [ - "bitflags", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", - "sqlite-wasm-rs", -] - [[package]] name = "rustc_version" version = "0.4.1" @@ -2349,18 +2282,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "sqlite-wasm-rs" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36" -dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/README.md b/README.md index 4b362ca..cf2664e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and - **Single binary** — UI client, daemon, and providers in one `/usr/bin/owlry` - **Client/daemon architecture** — Daemon (`owlry -d`) keeps providers warm; UI appears instantly - **Built-in providers** — Apps, PATH commands, calculator, unit/currency converter, power actions -- **Optional providers** (compiled in via `--features full` on AUR) — Bookmarks (Firefox + Chromium), clipboard history, emoji, SSH hosts, systemd user units, web search, filesystem search +- **Optional providers** (compiled in via `--features full` on AUR) — Clipboard history, emoji, SSH hosts, systemd user units, web search, filesystem search - **Fuzzy search with tags** — Fast matching across names, descriptions, category tags - **Config profiles** — Named mode presets for different workflows - **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:power`, `:uuctl`, `:tag:X`, etc. @@ -69,7 +69,6 @@ Cargo features (pick a subset if you don't need everything): | `conv` | Unit & currency converter | yes | | `power` | Shutdown/reboot/lock | yes | | `dmenu` | Pipe-based selection | yes | -| `bookmarks` | Firefox + Chromium bookmarks (rusqlite) | opt-in | | `clipboard` | Clipboard history (`cliphist`) | opt-in | | `emoji` | Emoji picker (`wl-clipboard`) | opt-in | | `ssh` | SSH hosts from `~/.ssh/config` | opt-in | @@ -207,7 +206,6 @@ pacman -Ssq | owlry dmenu -p "install" | xargs sudo pacman -S | `:power` (`:sys`, `:system`) | Power & session actions | `:power shutdown` | | `:calc` | Calculator | `:calc sqrt(16)` | | `:conv` | Converter | `:conv 5 ft to m` | -| `:bm` | Bookmarks | `:bm github` | | `:clip` | Clipboard | `:clip password` | | `:emoji` | Emoji | `:emoji heart` | | `:ssh` | SSH hosts | `:ssh server` | @@ -279,7 +277,6 @@ calculator = true # `=` or :calc converter = true # `>` or :conv power = true # `:power` shutdown/reboot/lock (alias: system) systemd = true # `:uuctl` user units (alias: uuctl) -bookmarks = true # Firefox + Chromium clipboard = true # via cliphist emoji = true # picker via wl-clipboard ssh = true # ~/.ssh/config hosts @@ -382,6 +379,7 @@ See [ROADMAP.md](ROADMAP.md) for feature ideas and [docs/RESTRUCTURE-V2.md](docs Headline upcoming work: - **Lua-driven configuration** (2.1 / 3.0) — `~/.config/owlry/init.lua` replaces TOML. User-defined providers via `owlry.provider {}` in the same file (Hyprland-style configs-as-code). `owlry migrate-config` lands at the same time. - **Widget providers return** — weather, MPRIS media controls, pomodoro timer. Deferred from 2.0 while the UI positioning is reworked. +- **Bookmarks return** — Firefox + Chromium. Deferred from 2.0 to avoid a hard rusqlite/`libsqlite3-sys` dep in the chroot build path; returns with a pure-Rust reader (likely via Firefox's JSON backup files). ## License @@ -392,5 +390,4 @@ GNU General Public License v3.0 — see [LICENSE](LICENSE). - [GTK4](https://gtk.org/) — UI toolkit - [gtk4-layer-shell](https://github.com/wmww/gtk4-layer-shell) — Wayland Layer Shell - [fuzzy-matcher](https://crates.io/crates/fuzzy-matcher) — Fuzzy search -- [rusqlite](https://crates.io/crates/rusqlite) — Bundled SQLite for bookmarks - [expr-solver-lib](https://crates.io/crates/expr-solver-lib) — Calculator backend diff --git a/ROADMAP.md b/ROADMAP.md index 6929e33..2597dbb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,6 +22,9 @@ owlry.provider { ### Widget providers return Weather, MPRIS media controls, and pomodoro timer were deferred from 2.0 while the widget-row UI is redesigned. They'll come back as a feature group once the placement model is settled. ([D20 in the v2 plan](docs/RESTRUCTURE-V2.md).) +### Bookmarks return +Firefox + Chromium bookmarks were deferred from 2.0 — the `rusqlite` dep used to read Firefox's `places.sqlite` made the AUR chroot build brittle (`libsqlite3-sys`'s `bundled` feature kept slipping out of the resolved feature graph). Returns when we wire up a pure-Rust path: Chromium's bookmarks file is already JSON, and Firefox exposes JSON backups under `~/.mozilla/firefox//bookmarkbackups/`. No SQLite required. + --- ## High Value, Low Effort diff --git a/aur/owlry/.SRCINFO b/aur/owlry/.SRCINFO index c8bd311..2deee8c 100644 --- a/aur/owlry/.SRCINFO +++ b/aur/owlry/.SRCINFO @@ -17,13 +17,13 @@ pkgbase = owlry provides = owlry-core provides = owlry-lua provides = owlry-rune - provides = owlry-plugin-bookmarks provides = owlry-plugin-clipboard provides = owlry-plugin-emoji provides = owlry-plugin-filesearch provides = owlry-plugin-ssh provides = owlry-plugin-systemd provides = owlry-plugin-websearch + provides = owlry-plugin-bookmarks provides = owlry-plugin-media provides = owlry-plugin-pomodoro provides = owlry-plugin-weather @@ -38,13 +38,13 @@ pkgbase = owlry conflicts = owlry-core conflicts = owlry-lua conflicts = owlry-rune - conflicts = owlry-plugin-bookmarks conflicts = owlry-plugin-clipboard conflicts = owlry-plugin-emoji conflicts = owlry-plugin-filesearch conflicts = owlry-plugin-ssh conflicts = owlry-plugin-systemd conflicts = owlry-plugin-websearch + conflicts = owlry-plugin-bookmarks conflicts = owlry-plugin-media conflicts = owlry-plugin-pomodoro conflicts = owlry-plugin-weather @@ -59,13 +59,13 @@ pkgbase = owlry replaces = owlry-core replaces = owlry-lua replaces = owlry-rune - replaces = owlry-plugin-bookmarks replaces = owlry-plugin-clipboard replaces = owlry-plugin-emoji replaces = owlry-plugin-filesearch replaces = owlry-plugin-ssh replaces = owlry-plugin-systemd replaces = owlry-plugin-websearch + replaces = owlry-plugin-bookmarks replaces = owlry-plugin-media replaces = owlry-plugin-pomodoro replaces = owlry-plugin-weather diff --git a/aur/owlry/PKGBUILD b/aur/owlry/PKGBUILD index 7f477b4..0b3b541 100644 --- a/aur/owlry/PKGBUILD +++ b/aur/owlry/PKGBUILD @@ -35,15 +35,15 @@ _v2_replaced=( 'owlry-lua' 'owlry-rune' # Plugins folded into owlry as feature-gated modules. - 'owlry-plugin-bookmarks' 'owlry-plugin-clipboard' 'owlry-plugin-emoji' 'owlry-plugin-filesearch' 'owlry-plugin-ssh' 'owlry-plugin-systemd' 'owlry-plugin-websearch' - # Deferred providers (D20); package replaced so users get a clean + # Deferred providers (D20+); package replaced so users get a clean # transition. Functionality returns in a later 2.x release. + 'owlry-plugin-bookmarks' 'owlry-plugin-media' 'owlry-plugin-pomodoro' 'owlry-plugin-weather' diff --git a/crates/owlry/Cargo.toml b/crates/owlry/Cargo.toml index 682efbb..fa6cc04 100644 --- a/crates/owlry/Cargo.toml +++ b/crates/owlry/Cargo.toml @@ -60,12 +60,6 @@ signal-hook = "0.3" expr-solver-lib = "1" reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "blocking"] } -# Optional providers (gated by cargo features below). -# Features declared on the bookmarks feature row, not inline here, so the -# `bundled` flag propagates cleanly to libsqlite3-sys regardless of how -# downstream consumers compose features. -rusqlite = { version = "0.39", optional = true } - # Async oneshot channel (background thread -> main loop) futures-channel = "0.3" @@ -84,7 +78,6 @@ dev-logging = [] # Optional providers (compiled in when enabled). # The AUR PKGBUILD builds with --features "full" to ship everything. -bookmarks = ["dep:rusqlite", "rusqlite/bundled"] clipboard = [] emoji = [] filesearch = [] @@ -92,8 +85,11 @@ ssh = [] systemd = [] websearch = [] +# Bookmarks deferred for 2.0 alongside the widget providers (D20+). +# Returns in a later 2.x release with a pure-Rust path that doesn't pull +# in libsqlite3-sys for Firefox's places.sqlite. + full = [ - "bookmarks", "clipboard", "emoji", "filesearch", diff --git a/crates/owlry/src/config/mod.rs b/crates/owlry/src/config/mod.rs index bcc50b1..e89206d 100644 --- a/crates/owlry/src/config/mod.rs +++ b/crates/owlry/src/config/mod.rs @@ -176,9 +176,6 @@ pub struct ProvidersConfig { /// Enable systemd user units provider (alias: uuctl) #[serde(default = "default_true", alias = "uuctl")] pub systemd: bool, - /// Enable bookmarks provider (Firefox + Chromium) - #[serde(default = "default_true")] - pub bookmarks: bool, /// Enable clipboard history provider (requires cliphist) #[serde(default = "default_true")] pub clipboard: bool, @@ -216,7 +213,6 @@ impl Default for ProvidersConfig { converter: true, power: true, systemd: true, - bookmarks: true, clipboard: true, emoji: true, filesearch: true, diff --git a/crates/owlry/src/providers/bookmarks.rs b/crates/owlry/src/providers/bookmarks.rs deleted file mode 100644 index 12e8908..0000000 --- a/crates/owlry/src/providers/bookmarks.rs +++ /dev/null @@ -1,469 +0,0 @@ -//! Browser bookmarks provider. -//! -//! Reads bookmarks from Firefox (`places.sqlite` via `rusqlite`) and -//! Chromium-family browsers (Chrome, Chromium, Brave, Edge — JSON -//! `Bookmarks` files). Items launch via `xdg-open `. -//! -//! This is a static provider: `refresh()` populates `self.items`, and the -//! core fuzzy-matches against the cached list. - -use super::{ItemSource, LaunchItem, Provider, ProviderType}; -use rusqlite::{Connection, OpenFlags}; -use serde::Deserialize; -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; - -const TYPE_ID: &str = "bookmarks"; -const ICON: &str = "user-bookmarks-symbolic"; - -pub struct BookmarksProvider { - items: Vec, -} - -impl Default for BookmarksProvider { - fn default() -> Self { - Self::new() - } -} - -impl BookmarksProvider { - pub fn new() -> Self { - Self { items: Vec::new() } - } - - /// Cache directory for downloaded Firefox favicons. - fn favicon_cache_dir() -> Option { - dirs::cache_dir().map(|d| d.join("owlry/favicons")) - } - - fn ensure_favicon_cache_dir() -> Option { - Self::favicon_cache_dir().and_then(|dir| { - fs::create_dir_all(&dir).ok()?; - Some(dir) - }) - } - - /// Hash a URL into a stable cache filename. Collisions are acceptable — - /// the worst case is a favicon shown for the wrong bookmark. - fn url_to_cache_filename(url: &str) -> String { - use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - url.hash(&mut hasher); - format!("{:016x}.png", hasher.finish()) - } - - fn chromium_bookmark_paths() -> Vec { - let mut paths = Vec::new(); - if let Some(config_dir) = dirs::config_dir() { - // Chrome - paths.push(config_dir.join("google-chrome/Default/Bookmarks")); - paths.push(config_dir.join("google-chrome-stable/Default/Bookmarks")); - // Chromium - paths.push(config_dir.join("chromium/Default/Bookmarks")); - // Brave - paths.push(config_dir.join("BraveSoftware/Brave-Browser/Default/Bookmarks")); - // Edge - paths.push(config_dir.join("microsoft-edge/Default/Bookmarks")); - } - paths - } - - fn firefox_places_paths() -> Vec { - let mut paths = Vec::new(); - if let Some(home) = dirs::home_dir() { - let firefox_dir = home.join(".mozilla/firefox"); - if firefox_dir.exists() - && let Ok(entries) = fs::read_dir(&firefox_dir) - { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - let places = path.join("places.sqlite"); - if places.exists() { - paths.push(places); - } - } - } - } - } - paths - } - - /// Sibling `favicons.sqlite` next to a given `places.sqlite`, if present. - fn firefox_favicons_path(places_path: &Path) -> Option { - let favicons = places_path.parent()?.join("favicons.sqlite"); - if favicons.exists() { - Some(favicons) - } else { - None - } - } - - fn read_chrome_bookmarks(path: &Path, items: &mut Vec) { - let content = match fs::read_to_string(path) { - Ok(c) => c, - Err(_) => return, - }; - - let bookmarks: ChromeBookmarks = match serde_json::from_str(&content) { - Ok(b) => b, - Err(_) => return, - }; - - if let Some(roots) = bookmarks.roots { - if let Some(bar) = roots.bookmark_bar { - Self::process_chrome_folder(&bar, items); - } - if let Some(other) = roots.other { - Self::process_chrome_folder(&other, items); - } - if let Some(synced) = roots.synced { - Self::process_chrome_folder(&synced, items); - } - } - } - - fn process_chrome_folder(folder: &ChromeBookmarkNode, items: &mut Vec) { - if let Some(ref children) = folder.children { - for child in children { - match child.node_type.as_deref() { - Some("url") => { - if let Some(ref url) = child.url { - let name = child.name.clone().unwrap_or_else(|| url.clone()); - items.push(LaunchItem { - id: format!("bookmark:{}", url), - name, - description: Some(url.clone()), - icon: Some(ICON.to_string()), - provider: ProviderType::Plugin(TYPE_ID.into()), - command: format!("xdg-open '{}'", url.replace('\'', "'\\''")), - terminal: false, - tags: vec!["bookmark".to_string(), "chrome".to_string()], - source: ItemSource::Core, - }); - } - } - Some("folder") => { - Self::process_chrome_folder(child, items); - } - _ => {} - } - } - } - } - - /// Read Firefox bookmarks via `rusqlite`. Copies the DB (+ WAL) into a - /// temp file first so we can open in read-only mode without contesting - /// the lock Firefox holds while running. - fn read_firefox_bookmarks(places_path: &Path, items: &mut Vec) { - let temp_dir = std::env::temp_dir(); - let pid = std::process::id(); - let temp_db = temp_dir.join(format!("owlry_places_{}.sqlite", pid)); - - if fs::copy(places_path, &temp_db).is_err() { - return; - } - - 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); - } - - 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(); - let bookmarks = Self::fetch_firefox_bookmarks(&temp_db, &temp_favicons, cache_dir.as_ref()); - - 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")); - - for (title, url, favicon_path) in bookmarks { - let icon = favicon_path.unwrap_or_else(|| ICON.to_string()); - items.push(LaunchItem { - id: format!("bookmark:firefox:{}", url), - name: title, - description: Some(url.clone()), - icon: Some(icon), - provider: ProviderType::Plugin(TYPE_ID.into()), - command: format!("xdg-open '{}'", url.replace('\'', "'\\''")), - terminal: false, - tags: vec!["bookmark".to_string(), "firefox".to_string()], - source: ItemSource::Core, - }); - } - } - - /// Run the bookmark SELECT against `places.sqlite` and, if available, - /// fetch + cache favicons from `favicons.sqlite`. - fn fetch_firefox_bookmarks( - places_path: &Path, - favicons_path: &Path, - cache_dir: Option<&PathBuf>, - ) -> Vec<(String, String, Option)> { - let conn = match Connection::open_with_flags( - places_path, - OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) { - Ok(c) => c, - Err(_) => return Vec::new(), - }; - - // type=1 means URL bookmarks (not folders, separators, etc.). We - // skip Firefox-internal `place:` and `about:` URLs. - let query = r#" - SELECT b.title, p.url - FROM moz_bookmarks b - JOIN moz_places p ON b.fk = p.id - WHERE b.type = 1 - AND p.url NOT LIKE 'place:%' - AND p.url NOT LIKE 'about:%' - AND b.title IS NOT NULL - AND b.title != '' - ORDER BY b.dateAdded DESC - LIMIT 500 - "#; - - let mut stmt = match conn.prepare(query) { - Ok(s) => s, - Err(_) => return Vec::new(), - }; - - let bookmarks: Vec<(String, String)> = stmt - .query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - }) - .ok() - .map(|rows| rows.filter_map(|r| r.ok()).collect()) - .unwrap_or_default(); - - let cache_dir = match cache_dir { - Some(c) => c, - None => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(), - }; - - let fav_conn = match Connection::open_with_flags( - favicons_path, - OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) { - Ok(c) => c, - Err(_) => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(), - }; - - let mut results = Vec::new(); - for (title, url) in bookmarks { - let favicon_path = Self::get_favicon_for_url(&fav_conn, &url, cache_dir); - results.push((title, url, favicon_path)); - } - - results - } - - /// Look up a favicon for `page_url` in Firefox's `favicons.sqlite`, writing - /// the blob to the cache directory on first hit. Returns the cached path - /// (or None if no favicon / write failed). - fn get_favicon_for_url(conn: &Connection, page_url: &str, cache_dir: &Path) -> Option { - let cache_filename = Self::url_to_cache_filename(page_url); - let cache_path = cache_dir.join(&cache_filename); - if cache_path.exists() { - return Some(cache_path.to_string_lossy().to_string()); - } - - // Prefer the 32px-ish icon if multiple sizes exist. - let query = r#" - SELECT i.data - FROM moz_pages_w_icons p - JOIN moz_icons_to_pages ip ON p.id = ip.page_id - JOIN moz_icons i ON ip.icon_id = i.id - WHERE p.page_url = ? - AND i.data IS NOT NULL - ORDER BY ABS(i.width - 32) ASC - LIMIT 1 - "#; - - let data: Option> = conn.query_row(query, [page_url], |row| row.get(0)).ok(); - - let data = data?; - if data.is_empty() { - return None; - } - - let mut file = fs::File::create(&cache_path).ok()?; - file.write_all(&data).ok()?; - - Some(cache_path.to_string_lossy().to_string()) - } -} - -impl Provider for BookmarksProvider { - fn name(&self) -> &str { - "Bookmarks" - } - - fn provider_type(&self) -> ProviderType { - ProviderType::Plugin(TYPE_ID.into()) - } - - fn refresh(&mut self) { - self.items.clear(); - - for path in Self::chromium_bookmark_paths() { - if path.exists() { - Self::read_chrome_bookmarks(&path, &mut self.items); - } - } - - for path in Self::firefox_places_paths() { - Self::read_firefox_bookmarks(&path, &mut self.items); - } - } - - fn items(&self) -> &[LaunchItem] { - &self.items - } - - fn prefix(&self) -> Option<&str> { - Some(":bm") - } - - fn icon(&self) -> &str { - ICON - } - - fn tab_label(&self) -> Option<&str> { - Some("Bookmarks") - } - - fn search_noun(&self) -> Option<&str> { - Some("bookmarks") - } -} - -// Chrome's `Bookmarks` JSON layout. -#[derive(Debug, Deserialize)] -struct ChromeBookmarks { - roots: Option, -} - -#[derive(Debug, Deserialize)] -struct ChromeBookmarkRoots { - bookmark_bar: Option, - other: Option, - synced: Option, -} - -#[derive(Debug, Deserialize)] -struct ChromeBookmarkNode { - name: Option, - url: Option, - #[serde(rename = "type")] - node_type: Option, - children: Option>, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_bookmarks_state_new() { - let p = BookmarksProvider::new(); - assert!(p.items().is_empty()); - } - - #[test] - fn test_chromium_paths() { - let paths = BookmarksProvider::chromium_bookmark_paths(); - // Should have at least some paths configured (5 known browsers). - assert!(!paths.is_empty()); - } - - #[test] - fn test_firefox_paths() { - // Path detection should not panic, even if Firefox isn't installed. - let paths = BookmarksProvider::firefox_places_paths(); - let _ = paths.len(); - } - - #[test] - fn test_parse_chrome_bookmarks() { - let json = r#"{ - "roots": { - "bookmark_bar": { - "type": "folder", - "children": [ - { - "type": "url", - "name": "Example", - "url": "https://example.com" - } - ] - } - } - }"#; - - let bookmarks: ChromeBookmarks = serde_json::from_str(json).unwrap(); - assert!(bookmarks.roots.is_some()); - - let roots = bookmarks.roots.unwrap(); - assert!(roots.bookmark_bar.is_some()); - - let bar = roots.bookmark_bar.unwrap(); - assert!(bar.children.is_some()); - assert_eq!(bar.children.unwrap().len(), 1); - } - - #[test] - fn test_process_folder() { - let mut items = Vec::new(); - - let folder = ChromeBookmarkNode { - name: Some("Test Folder".to_string()), - url: None, - node_type: Some("folder".to_string()), - children: Some(vec![ChromeBookmarkNode { - name: Some("Test Bookmark".to_string()), - url: Some("https://test.com".to_string()), - node_type: Some("url".to_string()), - children: None, - }]), - }; - - BookmarksProvider::process_chrome_folder(&folder, &mut items); - assert_eq!(items.len(), 1); - assert_eq!(items[0].name, "Test Bookmark"); - assert_eq!(items[0].provider, ProviderType::Plugin("bookmarks".into())); - assert_eq!(items[0].source, ItemSource::Core); - assert!(items[0].command.starts_with("xdg-open '")); - } - - #[test] - fn test_url_escaping() { - let url = "https://example.com/path?query='test'"; - let command = format!("xdg-open '{}'", url.replace('\'', "'\\''")); - assert!(command.contains("'\\''")); - } - - #[test] - fn provider_type_is_bookmarks_plugin() { - let p = BookmarksProvider::new(); - assert_eq!(p.provider_type(), ProviderType::Plugin("bookmarks".into())); - } - - #[test] - fn provider_prefix_is_bm() { - let p = BookmarksProvider::new(); - assert_eq!(p.prefix(), Some(":bm")); - } -} diff --git a/crates/owlry/src/providers/mod.rs b/crates/owlry/src/providers/mod.rs index f837cc8..1a44cf6 100644 --- a/crates/owlry/src/providers/mod.rs +++ b/crates/owlry/src/providers/mod.rs @@ -7,8 +7,6 @@ pub mod dmenu; pub(crate) mod power; // Optional feature-gated providers -#[cfg(feature = "bookmarks")] -pub(crate) mod bookmarks; #[cfg(feature = "clipboard")] pub(crate) mod clipboard; #[cfg(feature = "emoji")] @@ -277,11 +275,6 @@ impl ProviderManager { info!("Registered built-in power provider"); } - #[cfg(feature = "bookmarks")] - if cfg_snapshot.bookmarks { - core_providers.push(Box::new(bookmarks::BookmarksProvider::new())); - info!("Registered bookmarks provider"); - } #[cfg(feature = "clipboard")] if cfg_snapshot.clipboard { core_providers.push(Box::new(clipboard::ClipboardProvider::new())); diff --git a/docs/RESTRUCTURE-V2.md b/docs/RESTRUCTURE-V2.md index 3eeb438..8d8541d 100644 --- a/docs/RESTRUCTURE-V2.md +++ b/docs/RESTRUCTURE-V2.md @@ -34,6 +34,7 @@ All design decisions agreed before work started. These are load-bearing — do n | D19 | **Lua sandbox** (Phase 3): filesystem read + process spawn allowed; no network by default | Launcher needs to read configs and spawn commands. Network access (HTTP, sockets) requires explicit config opt-in. | | D20 | **Widget providers (`weather`, `media`, `pomodoro`) on hold** | UI positioning unresolved. **Excluded from Phase 1 conversion entirely** — not pulled in, not compiled in. Revisit as a dedicated workstream after 2.0 ships. | | D21 | **Hot-reload of `init.lua` on save** (Phase 3) | `notify` crate already a dep. Watch `~/.config/owlry/init.lua` and any `require`d files; re-run config eval on change. | +| D22 | **Bookmarks provider deferred too** | The `rusqlite` dep (for Firefox `places.sqlite`) caused `libsqlite3-sys/bundled` to fall out of the resolved feature graph in clean Arch chroots — every chroot build linked against system sqlite or failed with `sqlite3_*` undefined-symbol errors. Rather than fight Cargo's resolver, defer the bookmarks provider alongside the widgets. Returns when we wire a pure-Rust path (Firefox JSON backups under `bookmarkbackups/`; Chromium's `Bookmarks` is already JSON). | --- @@ -236,7 +237,7 @@ Subtasks (tracked in TaskList): 3. **Delete C-ABI plugin system** — `plugins/` dir, `native_provider.rs`, `lua_provider.rs`, `owlry-plugin-api` crate; strip `ProviderManager::new_with_config()` of plugin loading 4. **Delete Rune + Lua runtime crates** — `crates/owlry-rune/`, `crates/owlry-lua/` 5. **Delete config_editor + scripts providers** -6. **Convert 8 plugins to native Provider impls** — bookmarks, clipboard, emoji, ssh, systemd, websearch, filesearch. Pull source from `owlry-plugins/crates/owlry-plugin-*`, convert `extern "C"` vtable → `impl Provider` / `impl DynamicProvider`. Per-plugin mechanical work. **Widgets (weather, media, pomodoro) excluded per D20; scripts excluded per D12.** +6. **Convert 6 plugins to native Provider impls** — clipboard, emoji, ssh, systemd, websearch, filesearch. Pull source from `owlry-plugins/crates/owlry-plugin-*`, convert `extern "C"` vtable → `impl Provider` / `impl DynamicProvider`. Per-plugin mechanical work. **Widgets (weather, media, pomodoro) excluded per D20; scripts excluded per D12; bookmarks excluded per D22.** 7. **Wire cargo features per provider** — `#[cfg(feature = "...")]` gating; `default` / `full` feature groups 8. **Rename `sys` → `power`** — file, type_id (in CLI mode mapping table), `:sys` kept as alias, config key `providers.system` → `providers.power` (with TOML migration shim that reads the old name) 9. **CLI restructure** — new clap shape (subcommands `daemon`, `dmenu`, `doctor`, `providers`, `config`, `migrate-config`); drop entire `plugin` subcommand tree; daemon mode via `owlry -d` / `owlry daemon` @@ -316,7 +317,7 @@ Mechanical pattern for each plugin: drop `extern "C"` exports, `PluginItem` → | Plugin | Kind | Submenu? | Per-keystroke? | Notes | |---|---|---|---|---| -| bookmarks | Static | no | no | SQLite for Firefox; bundled rusqlite | +| ~~bookmarks~~ | DEFER | — | — | **D22** — rusqlite/bundled fragile in chroot; returns with a pure-Rust reader | | clipboard | Static | yes (?) | no | Uses `cliphist`, `wl-clipboard` | | emoji | Static | no | no | Bundled emoji data; writes via `wl-clipboard` | | ssh | Static | no | no | Parses `~/.ssh/config` | From 41e794f4d541e85e075da47119487bcda887a323 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 03:19:14 +0200 Subject: [PATCH 22/23] build(aur): disable debug subpackage (options=!debug) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit makepkg in clean chroots defaults to OPTIONS+=(debug), which tries to split debug symbols into an owlry-debug subpackage. Cargo's release profile already strips the binary at compile time (strip = true in workspace Cargo.toml), so the debug split finds nothing and emits a benign-but-noisy 'No debugging symbols' warning followed by an empty owlry-debug-2.0.0-1-x86_64.pkg.tar.zst. Set options=('!debug') so makepkg skips debug splitting entirely. The released package is unchanged from the user's perspective; we just stop producing the empty subpackage and the namcap warning that came with it. Leaves the unrelated namcap warnings about transitively-satisfied deps (glib2, openssl, pango, glibc, libgcc) untouched — those are normal for Rust binaries pulled through GTK4 and don't need explicit listing in depends=. The 'checkpkg: target not found: owlry' warning will auto- resolve once 2.0.0 is published to AUR. --- aur/owlry/.SRCINFO | 1 + aur/owlry/PKGBUILD | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/aur/owlry/.SRCINFO b/aur/owlry/.SRCINFO index 2deee8c..f4f816a 100644 --- a/aur/owlry/.SRCINFO +++ b/aur/owlry/.SRCINFO @@ -77,6 +77,7 @@ pkgbase = owlry replaces = owlry-meta-widgets replaces = owlry-meta-tools replaces = owlry-meta-full + options = !debug source = owlry-2.0.0.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v2.0.0.tar.gz b2sums = SKIP diff --git a/aur/owlry/PKGBUILD b/aur/owlry/PKGBUILD index 0b3b541..265fcc4 100644 --- a/aur/owlry/PKGBUILD +++ b/aur/owlry/PKGBUILD @@ -67,6 +67,12 @@ provides=("${_v2_replaced[@]}") install=owlry.install +# Cargo's release profile strips the binary at compile time (strip = true +# in workspace Cargo.toml), so there are no debug symbols left for makepkg +# to extract into an owlry-debug subpackage. Disable debug splitting so the +# build doesn't ship a 0-byte debug package. +options=('!debug') + source=("$pkgname-$pkgver.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v$pkgver.tar.gz") b2sums=('SKIP') # populated by `just aur-update-pkg owlry` once the tag is pushed From 2cac6556f3700d2fe84397bd624b7db1c78ac5cb Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 13 May 2026 03:24:08 +0200 Subject: [PATCH 23/23] chore(owlry): bump version to 2.0.0 --- Cargo.lock | 2 +- crates/owlry/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e975510..e1fe159 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1823,7 +1823,7 @@ dependencies = [ [[package]] name = "owlry" -version = "2.0.0-dev" +version = "2.0.0" dependencies = [ "chrono", "clap", diff --git a/crates/owlry/Cargo.toml b/crates/owlry/Cargo.toml index fa6cc04..203bfa7 100644 --- a/crates/owlry/Cargo.toml +++ b/crates/owlry/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "owlry" -version = "2.0.0-dev" +version = "2.0.0" edition = "2024" rust-version = "1.90" description = "A lightweight, owl-themed application launcher for Wayland"