From a49f5127dc511859b995b67aacf9de1af2e93553 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 11:37:22 +0100 Subject: [PATCH 01/20] docs: add architecture split design spec and implementation plan --- .../plans/2026-03-26-architecture-split.md | 2363 +++++++++++++++++ .../2026-03-26-architecture-split-design.md | 458 ++++ 2 files changed, 2821 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-26-architecture-split.md create mode 100644 docs/superpowers/specs/2026-03-26-architecture-split-design.md diff --git a/docs/superpowers/plans/2026-03-26-architecture-split.md b/docs/superpowers/plans/2026-03-26-architecture-split.md new file mode 100644 index 0000000..bd3b993 --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-architecture-split.md @@ -0,0 +1,2363 @@ +# Owlry Architecture Split — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split owlry into a client/daemon architecture with two repos, enabling independent release cadence and clean separation between UI and backend. + +**Architecture:** `owlry-core` daemon manages plugins, providers, frecency, and config over a Unix socket IPC. `owlry` is a thin GTK4 client that connects to the daemon for search/launch. Plugins move to a separate `owlry-plugins` repo with independent versioning. + +**Tech Stack:** Rust 1.90+, GTK4 0.10, abi_stable 0.11, serde_json (IPC protocol), tokio or std Unix sockets + +**Spec:** `docs/superpowers/specs/2026-03-26-architecture-split-design.md` + +--- + +## File Structure Overview + +### New files to create + +**In `crates/owlry-core/`:** +- `Cargo.toml` — new crate (lib + bin) +- `src/lib.rs` — library root, re-exports +- `src/main.rs` — daemon entry point +- `src/server.rs` — Unix socket IPC server +- `src/ipc.rs` — shared IPC message types (request/response enums) + +**In `crates/owlry/`:** +- `src/client.rs` — IPC client connecting to daemon + +**In `owlry-plugins/`:** +- `Cargo.toml` — new workspace root +- `justfile` — plugin-specific build/release automation +- `README.md` + +**In `systemd/`:** +- `owlry-core.service` — systemd user service +- `owlry-core.socket` — socket activation unit + +### Files to move + +**From `crates/owlry/src/` → `crates/owlry-core/src/`:** +- `config/mod.rs` +- `data/mod.rs`, `data/frecency.rs` +- `filter.rs` +- `providers/mod.rs`, `providers/application.rs`, `providers/command.rs`, `providers/native_provider.rs`, `providers/lua_provider.rs` +- `plugins/` (entire directory — mod.rs, native_loader.rs, runtime_loader.rs, loader.rs, manifest.rs, registry.rs, commands.rs, error.rs, runtime.rs, api/) +- `notify.rs` +- `paths.rs` + +**From `crates/owlry-plugin-*` → `owlry-plugins/crates/owlry-plugin-*`:** +- All 14 plugin crates +- `owlry-lua`, `owlry-rune` runtime crates + +**From `aur/` → `owlry-plugins/aur/`:** +- All `owlry-plugin-*` directories +- `owlry-lua`, `owlry-rune` directories + +### Files to modify significantly + +- `crates/owlry/Cargo.toml` — remove backend deps, add owlry-core dep +- `crates/owlry/src/main.rs` — remove plugin subcommand handling (moves to owlry-core) +- `crates/owlry/src/app.rs` — replace direct provider/plugin calls with IPC client +- `crates/owlry/src/cli.rs` — remove `--providers`, add `--profile`, restructure +- `crates/owlry/src/ui/main_window.rs` — use IPC client instead of direct ProviderManager +- `Cargo.toml` (root) — remove plugin/runtime workspace members +- `justfile` — split into core justfile + plugins justfile +- `README.md` — update for new architecture +- `CLAUDE.md` — update for new structure +- All plugin `Cargo.toml` files — change owlry-plugin-api from path to git dep + +--- + +## Phase 0: Dependency Refresh + +### Task 1: Update all external dependencies to latest stable + +**Files:** +- Modify: `Cargo.toml` (root) +- Modify: `crates/owlry/Cargo.toml` +- Modify: `crates/owlry-plugin-api/Cargo.toml` +- Modify: all `crates/owlry-plugin-*/Cargo.toml` +- Modify: `crates/owlry-lua/Cargo.toml` +- Modify: `crates/owlry-rune/Cargo.toml` + +- [ ] **Step 1: Check current outdated dependencies** + +Run: `cd /home/cnachtigall/ssd/git/archive/owlibou/owlry && cargo outdated -R 2>/dev/null || cargo update --dry-run` + +Review output and identify which dependencies have newer versions available. + +- [ ] **Step 2: Update Cargo.lock with latest compatible versions** + +Run: `cargo update` + +This updates within existing semver constraints. + +- [ ] **Step 3: Review and bump version constraints in Cargo.toml files** + +For each crate, check if major version bumps are available for key dependencies: +- `gtk4`: check latest 0.x +- `abi_stable`: check latest +- `reqwest`: check latest +- `mlua`: check latest +- `rune`: check latest +- `clap`: check latest 4.x +- `serde`: check latest 1.x +- `chrono`: check latest 0.4.x + +Update version constraints in each `Cargo.toml` that uses them. + +- [ ] **Step 4: Build and verify** + +Run: `cargo build --workspace` +Run: `cargo test --workspace` +Run: `cargo clippy --workspace` + +All must pass. + +- [ ] **Step 5: Commit** + +```bash +git add Cargo.lock Cargo.toml crates/*/Cargo.toml +git commit -m "chore: update all dependencies to latest stable" +``` + +--- + +## Phase 1: Extract `owlry-core` as Library Crate + +### Task 2: Create `owlry-core` crate scaffold + +**Files:** +- Create: `crates/owlry-core/Cargo.toml` +- Create: `crates/owlry-core/src/lib.rs` +- Modify: `Cargo.toml` (root workspace — add member) + +- [ ] **Step 1: Create `crates/owlry-core/Cargo.toml`** + +```toml +[package] +name = "owlry-core" +version = "0.5.0" +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" + +[dependencies] +owlry-plugin-api = { path = "../owlry-plugin-api" } + +# Provider system +fuzzy-matcher = "0.3" +freedesktop-desktop-entry = "0.7" + +# Plugin loading +libloading = "0.8" +semver = "1" + +# Data & config +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +chrono = { version = "0.4", features = ["serde"] } +dirs = "5" + +# Logging & notifications +log = "0.4" +env_logger = "0.11" +notify-rust = "4" + +# Optional: embedded Lua runtime +mlua = { version = "0.10", features = ["luajit", "vendored", "serialize"], optional = true } +meval = { version = "0.2", optional = true } +reqwest = { version = "0.12", features = ["blocking", "json"], optional = true } + +[features] +default = [] +lua = ["mlua", "meval", "reqwest"] +dev-logging = [] +``` + +Note: Exact dependency versions should match what was updated in Task 1. The versions shown here are the current ones — adjust to whatever Task 1 settled on. + +- [ ] **Step 2: Create placeholder `crates/owlry-core/src/lib.rs`** + +```rust +pub mod config; +pub mod data; +pub mod filter; +pub mod notify; +pub mod paths; +pub mod plugins; +pub mod providers; +``` + +- [ ] **Step 3: Add `owlry-core` to workspace members** + +In root `Cargo.toml`, add `"crates/owlry-core"` to the `members` list. + +- [ ] **Step 4: Verify workspace resolves** + +Run: `cargo check -p owlry-core` + +Expected: Fails with missing modules (expected — we haven't moved files yet). + +- [ ] **Step 5: Commit scaffold** + +```bash +git add crates/owlry-core/Cargo.toml crates/owlry-core/src/lib.rs Cargo.toml +git commit -m "feat(owlry-core): scaffold new core crate" +``` + +--- + +### Task 3: Move backend modules from `owlry` to `owlry-core` + +**Files:** +- Move: `crates/owlry/src/config/` → `crates/owlry-core/src/config/` +- Move: `crates/owlry/src/data/` → `crates/owlry-core/src/data/` +- Move: `crates/owlry/src/filter.rs` → `crates/owlry-core/src/filter.rs` +- Move: `crates/owlry/src/providers/mod.rs` → `crates/owlry-core/src/providers/mod.rs` +- Move: `crates/owlry/src/providers/application.rs` → `crates/owlry-core/src/providers/application.rs` +- Move: `crates/owlry/src/providers/command.rs` → `crates/owlry-core/src/providers/command.rs` +- Move: `crates/owlry/src/providers/native_provider.rs` → `crates/owlry-core/src/providers/native_provider.rs` +- Move: `crates/owlry/src/providers/lua_provider.rs` → `crates/owlry-core/src/providers/lua_provider.rs` +- Move: `crates/owlry/src/plugins/` → `crates/owlry-core/src/plugins/` +- Move: `crates/owlry/src/notify.rs` → `crates/owlry-core/src/notify.rs` +- Move: `crates/owlry/src/paths.rs` → `crates/owlry-core/src/paths.rs` +- Keep in `crates/owlry/src/`: `main.rs`, `app.rs`, `cli.rs`, `theme.rs`, `ui/`, `providers/dmenu.rs` + +- [ ] **Step 1: Move all backend modules** + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry + +# Config, data, filter, notify, paths +cp -r crates/owlry/src/config crates/owlry-core/src/ +cp -r crates/owlry/src/data crates/owlry-core/src/ +cp crates/owlry/src/filter.rs crates/owlry-core/src/ +cp crates/owlry/src/notify.rs crates/owlry-core/src/ +cp crates/owlry/src/paths.rs crates/owlry-core/src/ + +# Providers (except dmenu.rs — stays in owlry) +mkdir -p crates/owlry-core/src/providers +cp crates/owlry/src/providers/mod.rs crates/owlry-core/src/providers/ +cp crates/owlry/src/providers/application.rs crates/owlry-core/src/providers/ +cp crates/owlry/src/providers/command.rs crates/owlry-core/src/providers/ +cp crates/owlry/src/providers/native_provider.rs crates/owlry-core/src/providers/ +cp crates/owlry/src/providers/lua_provider.rs crates/owlry-core/src/providers/ + +# Plugins (entire directory) +cp -r crates/owlry/src/plugins crates/owlry-core/src/ +``` + +- [ ] **Step 2: Verify `owlry-core` compiles as library** + +Run: `cargo check -p owlry-core` + +Fix any import path issues. The moved files may reference `crate::` paths that need updating. Common fixes: +- `crate::config::Config` stays as-is (now within owlry-core) +- `crate::paths::` stays as-is +- `crate::providers::` stays as-is +- `crate::data::` stays as-is + +- [ ] **Step 3: Commit moved files (copy phase)** + +```bash +git add crates/owlry-core/src/ +git commit -m "feat(owlry-core): move backend modules from owlry" +``` + +--- + +### Task 4: Wire `owlry` to depend on `owlry-core` as library + +**Files:** +- Modify: `crates/owlry/Cargo.toml` — add owlry-core dep, remove moved deps +- Modify: `crates/owlry/src/app.rs` — import from owlry_core instead of local modules +- Modify: `crates/owlry/src/main.rs` — import from owlry_core +- Modify: `crates/owlry/src/theme.rs` — import config types from owlry_core +- Modify: `crates/owlry/src/ui/main_window.rs` — import from owlry_core +- Modify: `crates/owlry/src/ui/result_row.rs` — import from owlry_core +- Modify: `crates/owlry/src/ui/submenu.rs` — import from owlry_core (if needed) +- Delete from `crates/owlry/src/`: `config/`, `data/`, `filter.rs`, `notify.rs`, `paths.rs`, `plugins/`, `providers/mod.rs`, `providers/application.rs`, `providers/command.rs`, `providers/native_provider.rs`, `providers/lua_provider.rs` +- Keep: `crates/owlry/src/providers/dmenu.rs` (restructure into standalone module) + +- [ ] **Step 1: Add `owlry-core` dependency to `owlry`** + +In `crates/owlry/Cargo.toml`, add: +```toml +owlry-core = { path = "../owlry-core" } +``` + +Remove dependencies that are now only used by owlry-core (keep only what owlry itself uses directly): +- Keep: `gtk4`, `gtk4-layer-shell`, `clap`, `log`, `env_logger`, `serde`, `toml`, `dirs` +- Remove: `fuzzy-matcher`, `freedesktop-desktop-entry`, `libloading`, `semver`, `notify-rust` +- Remove optional lua-related deps if they moved entirely to owlry-core + +- [ ] **Step 2: Update all `use` statements in owlry source files** + +Replace local module imports with owlry-core imports throughout `crates/owlry/src/`: + +```rust +// Before (local): +use crate::config::Config; +use crate::data::FrecencyStore; +use crate::filter::{ProviderFilter, ParsedQuery}; +use crate::providers::{ProviderManager, LaunchItem, ProviderType, Provider}; +use crate::plugins::native_loader::NativePluginLoader; +use crate::notify; +use crate::paths; + +// After (from owlry-core): +use owlry_core::config::Config; +use owlry_core::data::FrecencyStore; +use owlry_core::filter::{ProviderFilter, ParsedQuery}; +use owlry_core::providers::{ProviderManager, LaunchItem, ProviderType, Provider}; +use owlry_core::plugins::native_loader::NativePluginLoader; +use owlry_core::notify; +use owlry_core::paths; +``` + +Apply this across: `app.rs`, `main.rs`, `theme.rs`, `ui/main_window.rs`, `ui/result_row.rs`, `ui/submenu.rs`. + +- [ ] **Step 3: Restructure dmenu as standalone provider in owlry** + +`crates/owlry/src/providers/dmenu.rs` stays but needs its own module root. Create `crates/owlry/src/providers/mod.rs` that only contains the dmenu re-export: + +```rust +pub mod dmenu; +pub use dmenu::DmenuProvider; +``` + +The `DmenuProvider` imports `LaunchItem` and `Provider` from `owlry_core::providers`. + +- [ ] **Step 4: Delete moved source files from owlry** + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry +rm -rf crates/owlry/src/config +rm -rf crates/owlry/src/data +rm -rf crates/owlry/src/plugins +rm crates/owlry/src/filter.rs +rm crates/owlry/src/notify.rs +rm crates/owlry/src/paths.rs +rm crates/owlry/src/providers/application.rs +rm crates/owlry/src/providers/command.rs +rm crates/owlry/src/providers/native_provider.rs +rm crates/owlry/src/providers/lua_provider.rs +``` + +- [ ] **Step 5: Build and verify the full workspace** + +Run: `cargo build --workspace` +Run: `cargo test --workspace` + +The launcher should work exactly as before — same binary, just using owlry-core as a library internally. + +- [ ] **Step 6: Smoke test** + +Run: `cargo run -p owlry -- -m dmenu <<< $'option1\noption2\noption3'` + +Verify dmenu mode works. Then run the full launcher if possible: + +Run: `cargo run -p owlry` + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "refactor: wire owlry to use owlry-core as library dependency" +``` + +--- + +### Task 5: Update justfile for owlry-core + +**Files:** +- Modify: `justfile` + +- [ ] **Step 1: Add owlry-core build targets** + +Add these targets to the justfile: + +```just +# Build core daemon only +build-daemon: + cargo build -p owlry-core + +# Build core daemon release +release-daemon: + cargo build -p owlry-core --release + +# Run core daemon +run-daemon *ARGS: + cargo run -p owlry-core -- {{ARGS}} +``` + +- [ ] **Step 2: Update existing targets** + +Update `build-core` to clarify it builds the UI binary: + +```just +# Build UI binary only +build-ui: + cargo build -p owlry +``` + +Update `install-local` to install both binaries: + +```just +install-local: + cargo build -p owlry --release + cargo build -p owlry-core --release + sudo install -Dm755 target/release/owlry /usr/bin/owlry + sudo install -Dm755 target/release/owlry-core /usr/bin/owlry-core + # ... rest of plugin install unchanged +``` + +- [ ] **Step 3: Add bump target for owlry-core** + +Add `bump-core` and update `bump-all` to include owlry-core. + +- [ ] **Step 4: Verify justfile** + +Run: `just build` +Run: `just check` + +- [ ] **Step 5: Commit** + +```bash +git add justfile +git commit -m "chore: update justfile for owlry-core crate" +``` + +--- + +## Phase 2: Add IPC Layer + +### Task 6: Define shared IPC message types + +**Files:** +- Create: `crates/owlry-core/src/ipc.rs` +- Modify: `crates/owlry-core/src/lib.rs` — add `pub mod ipc;` + +- [ ] **Step 1: Write tests for IPC message serialization** + +Create `crates/owlry-core/tests/ipc_test.rs`: + +```rust +use owlry_core::ipc::{Request, Response, ResultItem, ProviderDesc}; + +#[test] +fn test_query_request_roundtrip() { + let req = Request::Query { + text: "fire".into(), + modes: Some(vec!["app".into(), "cmd".into()]), + }; + let json = serde_json::to_string(&req).unwrap(); + let parsed: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(req, parsed); +} + +#[test] +fn test_query_request_without_modes() { + let req = Request::Query { + text: "fire".into(), + modes: None, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(!json.contains("modes")); + let parsed: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(req, parsed); +} + +#[test] +fn test_launch_request_roundtrip() { + let req = Request::Launch { + item_id: "firefox.desktop".into(), + provider: "app".into(), + }; + let json = serde_json::to_string(&req).unwrap(); + let parsed: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(req, parsed); +} + +#[test] +fn test_results_response_roundtrip() { + let resp = Response::Results { + items: vec![ResultItem { + id: "firefox.desktop".into(), + title: "Firefox".into(), + description: "Web Browser".into(), + icon: "firefox".into(), + provider: "app".into(), + score: 95, + command: Some("firefox".into()), + tags: vec![], + }], + }; + let json = serde_json::to_string(&resp).unwrap(); + let parsed: Response = serde_json::from_str(&json).unwrap(); + assert_eq!(resp, parsed); +} + +#[test] +fn test_providers_response() { + let resp = Response::Providers { + list: vec![ProviderDesc { + id: "app".into(), + name: "Applications".into(), + prefix: Some(":app".into()), + icon: "application-x-executable".into(), + position: "normal".into(), + }], + }; + let json = serde_json::to_string(&resp).unwrap(); + let parsed: Response = serde_json::from_str(&json).unwrap(); + assert_eq!(resp, parsed); +} + +#[test] +fn test_error_response() { + let resp = Response::Error { + message: "plugin not found".into(), + }; + let json = serde_json::to_string(&resp).unwrap(); + let parsed: Response = serde_json::from_str(&json).unwrap(); + assert_eq!(resp, parsed); +} + +#[test] +fn test_toggle_request() { + let req = Request::Toggle; + let json = serde_json::to_string(&req).unwrap(); + let parsed: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(req, parsed); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p owlry-core --test ipc_test` + +Expected: compilation error — `ipc` module doesn't exist yet. + +- [ ] **Step 3: Implement IPC message types** + +Create `crates/owlry-core/src/ipc.rs`: + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Request { + Query { + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + modes: Option>, + }, + Launch { + item_id: String, + provider: String, + }, + Providers, + Refresh { + provider: String, + }, + Toggle, + Submenu { + plugin_id: String, + data: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Response { + Results { + items: Vec, + }, + Providers { + list: Vec, + }, + SubmenuItems { + items: Vec, + }, + Ack, + Error { + message: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ResultItem { + pub id: String, + pub title: String, + pub description: String, + pub icon: String, + pub provider: String, + pub score: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProviderDesc { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub prefix: Option, + pub icon: String, + pub position: String, +} +``` + +Add `pub mod ipc;` to `crates/owlry-core/src/lib.rs`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p owlry-core --test ipc_test` + +Expected: all 7 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/owlry-core/src/ipc.rs crates/owlry-core/src/lib.rs crates/owlry-core/tests/ +git commit -m "feat(owlry-core): define IPC message types with serde" +``` + +--- + +### Task 7: Implement IPC server in `owlry-core` + +**Files:** +- Create: `crates/owlry-core/src/server.rs` +- Modify: `crates/owlry-core/src/lib.rs` — add `pub mod server;` +- Modify: `crates/owlry-core/Cargo.toml` — may need to add deps + +- [ ] **Step 1: Write test for server socket creation and message handling** + +Create `crates/owlry-core/tests/server_test.rs`: + +```rust +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +use owlry_core::ipc::{Request, Response}; +use owlry_core::server::Server; + +fn temp_socket_path() -> PathBuf { + let dir = std::env::temp_dir().join(format!("owlry-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("test.sock") +} + +#[test] +fn test_server_accepts_connection_and_responds_to_providers() { + let sock = temp_socket_path(); + let sock2 = sock.clone(); + + let handle = thread::spawn(move || { + let server = Server::new(&sock2).unwrap(); + // Accept one connection, handle one request, then stop + server.handle_one_for_testing().unwrap(); + }); + + // Give server time to bind + thread::sleep(Duration::from_millis(100)); + + let mut stream = UnixStream::connect(&sock).unwrap(); + let req = serde_json::to_string(&Request::Providers).unwrap(); + writeln!(stream, "{}", req).unwrap(); + stream.flush().unwrap(); + + let mut reader = BufReader::new(&stream); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let resp: Response = serde_json::from_str(line.trim()).unwrap(); + + match resp { + Response::Providers { list } => { + // Server without plugins returns at least empty list + assert!(list.is_empty() || !list.is_empty()); + } + other => panic!("Expected Providers response, got {:?}", other), + } + + handle.join().unwrap(); + std::fs::remove_dir_all(sock.parent().unwrap()).ok(); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p owlry-core --test server_test` + +Expected: compilation error — `server` module doesn't exist. + +- [ ] **Step 3: Implement the IPC server** + +Create `crates/owlry-core/src/server.rs`: + +```rust +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use log::{error, info, warn}; + +use crate::config::Config; +use crate::data::FrecencyStore; +use crate::filter::ProviderFilter; +use crate::ipc::{Request, Response, ResultItem, ProviderDesc}; +use crate::providers::{LaunchItem, ProviderManager, ProviderType}; + +pub struct Server { + listener: UnixListener, + socket_path: PathBuf, + provider_manager: Arc>, + frecency: Arc>, + config: Arc, +} + +impl Server { + pub fn new(socket_path: &Path) -> std::io::Result { + if socket_path.exists() { + std::fs::remove_file(socket_path)?; + } + if let Some(parent) = socket_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let listener = UnixListener::bind(socket_path)?; + let config = Config::load_or_default(); + + let provider_manager = ProviderManager::new(&config); + let frecency = FrecencyStore::new(); + + info!("owlry-core listening on {}", socket_path.display()); + + Ok(Self { + listener, + socket_path: socket_path.to_path_buf(), + provider_manager: Arc::new(Mutex::new(provider_manager)), + frecency: Arc::new(Mutex::new(frecency)), + config: Arc::new(config), + }) + } + + pub fn run(&self) -> std::io::Result<()> { + for stream in self.listener.incoming() { + match stream { + Ok(stream) => { + let pm = Arc::clone(&self.provider_manager); + let frec = Arc::clone(&self.frecency); + let config = Arc::clone(&self.config); + std::thread::spawn(move || { + if let Err(e) = Self::handle_client(stream, &pm, &frec, &config) { + warn!("Client error: {}", e); + } + }); + } + Err(e) => error!("Accept error: {}", e), + } + } + Ok(()) + } + + fn handle_client( + stream: UnixStream, + provider_manager: &Arc>, + frecency: &Arc>, + config: &Arc, + ) -> std::io::Result<()> { + let reader = BufReader::new(stream.try_clone()?); + let mut writer = stream; + + for line in reader.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + + let request: Request = match serde_json::from_str(&line) { + Ok(r) => r, + Err(e) => { + let resp = Response::Error { + message: format!("Invalid request: {}", e), + }; + Self::send_response(&mut writer, &resp)?; + continue; + } + }; + + let response = Self::handle_request(request, provider_manager, frecency, config); + Self::send_response(&mut writer, &response)?; + } + Ok(()) + } + + fn handle_request( + request: Request, + provider_manager: &Arc>, + frecency: &Arc>, + config: &Arc, + ) -> Response { + match request { + Request::Query { text, modes } => { + let pm = provider_manager.lock().unwrap(); + let frec = frecency.lock().unwrap(); + + let filter = match &modes { + Some(m) => ProviderFilter::from_mode_strings(m), + None => ProviderFilter::all(), + }; + + let max_results = config.general.max_results.unwrap_or(50) as usize; + let results = pm.search_with_frecency( + &text, + max_results, + &filter, + &frec, + config.general.frecency_weight.unwrap_or(0.3), + None, + ); + + Response::Results { + items: results + .into_iter() + .map(|(item, score)| ResultItem { + id: item.id.clone(), + title: item.name.clone(), + description: item.description.clone().unwrap_or_default(), + icon: item.icon.clone().unwrap_or_default(), + provider: format!("{:?}", item.provider), + score, + command: item.command.clone(), + tags: item.tags.clone(), + }) + .collect(), + } + } + Request::Launch { item_id, provider: _ } => { + let mut frec = frecency.lock().unwrap(); + frec.record_launch(&item_id); + Response::Ack + } + Request::Providers => { + let pm = provider_manager.lock().unwrap(); + let providers = pm.available_providers(); + Response::Providers { + list: providers + .into_iter() + .map(|p| ProviderDesc { + id: p.id, + name: p.name, + prefix: p.prefix, + icon: p.icon, + position: p.position, + }) + .collect(), + } + } + Request::Refresh { provider } => { + let mut pm = provider_manager.lock().unwrap(); + pm.refresh_provider(&provider); + Response::Ack + } + Request::Toggle => { + // Toggle is handled by the UI client — daemon just acknowledges + Response::Ack + } + Request::Submenu { plugin_id, data } => { + let pm = provider_manager.lock().unwrap(); + match pm.query_submenu_actions(&plugin_id, &data, "") { + Some((_title, items)) => Response::SubmenuItems { + items: items + .into_iter() + .map(|item| ResultItem { + id: item.id.clone(), + title: item.name.clone(), + description: item.description.clone().unwrap_or_default(), + icon: item.icon.clone().unwrap_or_default(), + provider: format!("{:?}", item.provider), + score: 0, + command: item.command.clone(), + tags: item.tags.clone(), + }) + .collect(), + }, + None => Response::Error { + message: format!("No submenu for plugin '{}'", plugin_id), + }, + } + } + } + } + + fn send_response(writer: &mut UnixStream, response: &Response) -> std::io::Result<()> { + let json = serde_json::to_string(response).unwrap(); + writeln!(writer, "{}", json)?; + writer.flush() + } + + /// For testing: accept one connection, handle one request, then return + pub fn handle_one_for_testing(&self) -> std::io::Result<()> { + let (stream, _) = self.listener.accept()?; + let reader = BufReader::new(stream.try_clone()?); + let mut writer = stream; + + if let Some(Ok(line)) = reader.lines().next() { + if let Ok(request) = serde_json::from_str::(&line) { + let response = Self::handle_request( + request, + &self.provider_manager, + &self.frecency, + &self.config, + ); + Self::send_response(&mut writer, &response)?; + } + } + Ok(()) + } +} + +impl Drop for Server { + fn drop(&mut self) { + std::fs::remove_file(&self.socket_path).ok(); + } +} +``` + +**Dependency:** This task depends on Task 8 (daemon-friendly API surface). Implement Task 8 first, then come back to this task. The server calls `ProviderManager::new()`, `ProviderFilter::from_mode_strings()`, `pm.available_providers()`, and `pm.refresh_provider()` — all defined in Task 8. + +Add `pub mod server;` to `crates/owlry-core/src/lib.rs`. + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p owlry-core --test server_test` + +Fix any compilation issues. The test may need adjustments based on `ProviderManager::new()` signature. + +- [ ] **Step 5: Commit** + +```bash +git add crates/owlry-core/src/server.rs crates/owlry-core/src/lib.rs crates/owlry-core/tests/ +git commit -m "feat(owlry-core): implement IPC server over Unix socket" +``` + +--- + +### Task 8: Add daemon-friendly API surface to ProviderManager + +**Files:** +- Modify: `crates/owlry-core/src/providers/mod.rs` — add methods needed by server +- Modify: `crates/owlry-core/src/filter.rs` — add `from_mode_strings()` constructor + +The server (Task 7) needs several methods that the current `ProviderManager` doesn't have. This task adds them. + +- [ ] **Step 1: Add `ProviderFilter::from_mode_strings`** + +In `crates/owlry-core/src/filter.rs`, add: + +```rust +impl ProviderFilter { + /// Create a filter from a list of mode name strings (e.g., ["app", "cmd", "calc"]) + pub fn from_mode_strings(modes: &[String]) -> Self { + let mut filter = Self::none(); + for mode in modes { + if let Some(pt) = Self::provider_type_from_str(mode) { + filter.enable(pt); + } + } + filter + } + + /// Create a filter that accepts all providers + pub fn all() -> Self { + Self::new(None, None, None) + } + + /// Create a filter that accepts no providers (empty enabled set) + fn none() -> Self { + // Start with default filter, then clear all enabled providers. + // Exact implementation depends on ProviderFilter internals — + // if it uses a HashSet, start with an empty set. + // If it uses bool flags, set all to false. + let mut f = Self::default(); + // Clear all enabled providers + f + } + + fn provider_type_from_str(s: &str) -> Option { + match s { + "app" => Some(ProviderType::Application), + "cmd" => Some(ProviderType::Command), + "dmenu" => Some(ProviderType::Dmenu), + other => Some(ProviderType::Plugin(other.to_string())), + } + } +} +``` + +Exact implementation depends on current `ProviderFilter` internals — adapt to match. + +- [ ] **Step 2: Add `ProviderManager::new(&Config)` constructor** + +Currently `ProviderManager` is created via `with_native_plugins()`. Add a constructor that initializes from config, loads plugins, and sets up providers: + +```rust +impl ProviderManager { + pub fn new(config: &Config) -> Self { + // Load native plugins — move the logic from OwlryApp::load_native_plugins() + // in app.rs into this method. It currently: + // 1. Creates NativePluginLoader::new() + // 2. Calls loader.discover() + // 3. Iterates loader.into_plugins() creating NativeProvider instances + // 4. Filters by config.plugins.disabled_plugins if set + let native_providers = Self::load_native_plugins(config); + let mut pm = Self::with_native_plugins(native_providers); + pm.refresh_all(); + pm + } + + fn load_native_plugins(config: &Config) -> Vec { + use crate::plugins::native_loader::NativePluginLoader; + let mut loader = NativePluginLoader::new(); + if let Err(e) = loader.discover() { + log::warn!("Plugin discovery error: {}", e); + } + let plugins = loader.into_plugins(); + let mut providers = Vec::new(); + for plugin in plugins { + for info in (plugin.vtable().providers)() { + let provider = NativeProvider::new(plugin.clone(), info); + // Skip disabled plugins + if let Some(disabled) = config.plugins.as_ref().and_then(|p| p.disabled_plugins.as_ref()) { + if disabled.contains(&provider.type_id().to_string()) { + continue; + } + } + providers.push(provider); + } + } + providers + } +} +``` + +- [ ] **Step 3: Add `available_providers()` and `refresh_provider()` methods** + +```rust +impl ProviderManager { + pub fn available_providers(&self) -> Vec { + // Iterate over core providers + native providers and collect metadata. + // Core providers: Application, Command (always present). + // Native providers: iterate self.native_providers (or equivalent field). + let mut descs = vec![ + ProviderDescriptor { + id: "app".into(), + name: "Applications".into(), + prefix: Some(":app".into()), + icon: "application-x-executable".into(), + position: "normal".into(), + }, + ProviderDescriptor { + id: "cmd".into(), + name: "Commands".into(), + prefix: Some(":cmd".into()), + icon: "utilities-terminal".into(), + position: "normal".into(), + }, + ]; + // Add native plugin providers from self's internal list + // Map NativeProvider info to ProviderDescriptor + descs + } + + pub fn refresh_provider(&mut self, provider_id: &str) { + // Find the provider matching provider_id and call its refresh() method. + // For core providers, call the Provider trait's refresh(). + // For native providers, find by type_id and call refresh(). + } +} + +pub struct ProviderDescriptor { + pub id: String, + pub name: String, + pub prefix: Option, + pub icon: String, + pub position: String, +} +``` + +- [ ] **Step 4: Build and verify** + +Run: `cargo check -p owlry-core` +Run: `cargo test -p owlry-core` + +- [ ] **Step 5: Commit** + +```bash +git add crates/owlry-core/src/providers/mod.rs crates/owlry-core/src/filter.rs +git commit -m "feat(owlry-core): add daemon-friendly API to ProviderManager and ProviderFilter" +``` + +--- + +### Task 9: Create daemon binary entry point + +**Files:** +- Create: `crates/owlry-core/src/main.rs` +- Modify: `crates/owlry-core/Cargo.toml` — add `[[bin]]` target + +- [ ] **Step 1: Add binary target to Cargo.toml** + +In `crates/owlry-core/Cargo.toml`, add: + +```toml +[[bin]] +name = "owlry-core" +path = "src/main.rs" +``` + +- [ ] **Step 2: Implement daemon entry point** + +Create `crates/owlry-core/src/main.rs`: + +```rust +use std::path::PathBuf; + +use log::info; + +use owlry_core::paths; +use owlry_core::server::Server; + +fn socket_path() -> PathBuf { + let runtime_dir = std::env::var("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/tmp")); + runtime_dir.join("owlry").join("owlry.sock") +} + +fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); + + let sock = socket_path(); + info!("Starting owlry-core daemon..."); + + let server = match Server::new(&sock) { + Ok(s) => s, + Err(e) => { + eprintln!("Failed to start owlry-core: {}", e); + std::process::exit(1); + } + }; + + // Handle SIGTERM for graceful shutdown + let sock_cleanup = sock.clone(); + ctrlc::handle(move || { + std::fs::remove_file(&sock_cleanup).ok(); + std::process::exit(0); + }) + .ok(); + + if let Err(e) = server.run() { + eprintln!("Server error: {}", e); + std::process::exit(1); + } +} +``` + +Note: May need `ctrlc` crate or use `signal_hook` for signal handling. Alternatively, handle cleanup in `Drop`. + +- [ ] **Step 3: Add signal handling dependency if needed** + +If using `ctrlc` crate, add to `crates/owlry-core/Cargo.toml`: + +```toml +ctrlc = "3" +``` + +Or use standard library signal handling with `libc`. + +- [ ] **Step 4: Build daemon binary** + +Run: `cargo build -p owlry-core` +Run: `./target/debug/owlry-core &` (verify it starts and creates socket) +Run: `ls $XDG_RUNTIME_DIR/owlry/owlry.sock` (verify socket exists) +Run: `kill %1` (stop daemon) + +- [ ] **Step 5: Commit** + +```bash +git add crates/owlry-core/src/main.rs crates/owlry-core/Cargo.toml +git commit -m "feat(owlry-core): add daemon binary entry point" +``` + +--- + +### Task 10: Implement IPC client in `owlry` + +**Files:** +- Create: `crates/owlry/src/client.rs` + +- [ ] **Step 1: Write client tests** + +Create `crates/owlry/tests/client_test.rs`: + +```rust +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixListener; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +use owlry::client::CoreClient; + +fn temp_socket_path() -> PathBuf { + let dir = std::env::temp_dir().join(format!("owlry-client-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("test.sock") +} + +#[test] +fn test_client_connects_and_queries() { + let sock = temp_socket_path(); + let sock2 = sock.clone(); + + // Mock server: accept connection, read query, respond with results + let handle = thread::spawn(move || { + let listener = UnixListener::bind(&sock2).unwrap(); + let (stream, _) = listener.accept().unwrap(); + let reader = BufReader::new(stream.try_clone().unwrap()); + let mut writer = stream; + + for line in reader.lines() { + let line = line.unwrap(); + // Respond with empty results + let resp = r#"{"type":"results","items":[]}"#; + writeln!(writer, "{}", resp).unwrap(); + writer.flush().unwrap(); + break; + } + }); + + thread::sleep(Duration::from_millis(100)); + + let client = CoreClient::connect(&sock).unwrap(); + let results = client.query("test", None).unwrap(); + assert!(results.is_empty()); + + handle.join().unwrap(); + std::fs::remove_dir_all(sock.parent().unwrap()).ok(); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p owlry --test client_test` + +Expected: compilation error. + +- [ ] **Step 3: Implement the IPC client** + +Create `crates/owlry/src/client.rs`: + +```rust +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem}; + +pub struct CoreClient { + stream: UnixStream, + reader: BufReader, +} + +impl CoreClient { + pub fn connect(socket_path: &Path) -> std::io::Result { + let stream = UnixStream::connect(socket_path)?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + let reader = BufReader::new(stream.try_clone()?); + Ok(Self { stream, reader }) + } + + pub fn connect_or_start() -> std::io::Result { + let sock = Self::socket_path(); + + // Try direct connection first + if let Ok(client) = Self::connect(&sock) { + return Ok(client); + } + + // Try starting via systemd + let _ = Command::new("systemctl") + .args(["--user", "start", "owlry-core"]) + .status(); + + // Retry with backoff + for delay_ms in [100, 200, 400] { + std::thread::sleep(Duration::from_millis(delay_ms)); + if let Ok(client) = Self::connect(&sock) { + return Ok(client); + } + } + + Err(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "Could not connect to owlry-core daemon. Is it running?", + )) + } + + pub fn socket_path() -> PathBuf { + let runtime_dir = std::env::var("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/tmp")); + runtime_dir.join("owlry").join("owlry.sock") + } + + pub fn query(&mut self, text: &str, modes: Option>) -> std::io::Result> { + let request = Request::Query { + text: text.into(), + modes, + }; + self.send(&request)?; + + match self.receive()? { + Response::Results { items } => Ok(items), + Response::Error { message } => Err(std::io::Error::new( + std::io::ErrorKind::Other, + message, + )), + other => Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Unexpected response: {:?}", other), + )), + } + } + + pub fn launch(&mut self, item_id: &str, provider: &str) -> std::io::Result<()> { + let request = Request::Launch { + item_id: item_id.into(), + provider: provider.into(), + }; + self.send(&request)?; + self.receive()?; + Ok(()) + } + + pub fn providers(&mut self) -> std::io::Result> { + self.send(&Request::Providers)?; + match self.receive()? { + Response::Providers { list } => Ok(list), + Response::Error { message } => Err(std::io::Error::new( + std::io::ErrorKind::Other, + message, + )), + _ => Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Unexpected response", + )), + } + } + + pub fn toggle(&mut self) -> std::io::Result<()> { + self.send(&Request::Toggle)?; + self.receive()?; + Ok(()) + } + + pub fn submenu(&mut self, plugin_id: &str, data: &str) -> std::io::Result> { + let request = Request::Submenu { + plugin_id: plugin_id.into(), + data: data.into(), + }; + self.send(&request)?; + match self.receive()? { + Response::SubmenuItems { items } => Ok(items), + Response::Error { message } => Err(std::io::Error::new( + std::io::ErrorKind::Other, + message, + )), + _ => Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Unexpected response", + )), + } + } + + fn send(&mut self, request: &Request) -> std::io::Result<()> { + let json = serde_json::to_string(request).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, e) + })?; + writeln!(self.stream, "{}", json)?; + self.stream.flush() + } + + fn receive(&mut self) -> std::io::Result { + let mut line = String::new(); + self.reader.read_line(&mut line)?; + serde_json::from_str(line.trim()).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, e) + }) + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p owlry --test client_test` + +Expected: pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/owlry/src/client.rs crates/owlry/tests/ +git commit -m "feat(owlry): implement IPC client for daemon communication" +``` + +--- + +### Task 11: Wire UI to use IPC client + +**Files:** +- Modify: `crates/owlry/src/app.rs` — replace direct plugin/provider calls with client +- Modify: `crates/owlry/src/ui/main_window.rs` — use client for search/launch +- Modify: `crates/owlry/src/main.rs` — add client initialization + +This is the largest single task. The UI currently calls `ProviderManager` directly. It needs to go through the IPC client instead, except in dmenu mode. + +- [ ] **Step 1: Refactor `app.rs` — remove direct plugin loading** + +In `on_activate()`, replace: +- `load_native_plugins()` call → removed (daemon loads plugins) +- `ProviderManager::with_native_plugins()` → `CoreClient::connect_or_start()` +- `load_lua_plugins()` → removed (daemon loads) + +The app creates a `CoreClient` and passes it to `MainWindow`. + +- [ ] **Step 2: Refactor `MainWindow` to accept `CoreClient`** + +Replace `ProviderManager` and `FrecencyStore` fields with `CoreClient`: +- Search handler: calls `client.query(text, modes)` instead of `pm.search_with_frecency()` +- Launch handler: calls `client.launch(id, provider)` instead of direct command execution +- Provider tabs: calls `client.providers()` on init +- Submenu: calls `client.submenu(plugin_id, data)` + +The `CoreClient` is wrapped in `Rc>` for GTK signal handlers. + +- [ ] **Step 3: Keep dmenu mode as direct (no client)** + +In `app.rs`, if `args.mode` contains only `dmenu`: +- Skip client connection entirely +- Use `DmenuProvider` directly as before +- This path doesn't change + +- [ ] **Step 4: Build and test** + +Run: `cargo build -p owlry` + +Start daemon in one terminal: `cargo run -p owlry-core` +Test UI in another: `cargo run -p owlry` +Test dmenu: `echo "a\nb\nc" | cargo run -p owlry -- -m dmenu` + +- [ ] **Step 5: Commit** + +```bash +git add crates/owlry/src/ +git commit -m "refactor(owlry): wire UI to use IPC client instead of direct provider calls" +``` + +--- + +### Task 12: Implement toggle behavior + +**Files:** +- Modify: `crates/owlry/src/main.rs` — check for existing instance +- Modify: `crates/owlry-core/src/server.rs` — track client visibility state + +- [ ] **Step 1: Add instance detection** + +In `owlry` `main.rs`, before creating the GTK Application: +1. Try connecting to daemon +2. If another UI instance is already connected, send `Toggle` → daemon tells the other instance to close +3. If no other instance, proceed normally + +Implementation options: +- Lock file at `$XDG_RUNTIME_DIR/owlry/owlry-ui.lock` +- Or: daemon tracks connected UI clients and responds to Toggle with visibility state + +- [ ] **Step 2: Implement using lock file approach** + +```rust +use std::fs; +use std::os::unix::fs::OpenOptionsExt; + +fn try_acquire_lock() -> Option { + let lock_path = CoreClient::socket_path() + .parent() + .unwrap() + .join("owlry-ui.lock"); + + fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&lock_path) + .ok() + .and_then(|f| { + use std::os::unix::io::AsRawFd; + let fd = f.as_raw_fd(); + let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) }; + if ret == 0 { Some(f) } else { None } + }) +} +``` + +If lock acquisition fails, another instance is running → send toggle signal and exit. + +- [ ] **Step 3: Test toggle** + +Run daemon: `cargo run -p owlry-core &` +Run UI: `cargo run -p owlry &` +Run UI again: `cargo run -p owlry` → should close the first instance + +- [ ] **Step 4: Commit** + +```bash +git add crates/owlry/src/main.rs +git commit -m "feat(owlry): implement toggle behavior for repeated invocations" +``` + +--- + +### Task 13: Add config profiles and update CLI + +**Files:** +- Modify: `crates/owlry/src/cli.rs` — remove `--providers`, add `--profile` +- Modify: `crates/owlry-core/src/config/mod.rs` — add `ProfileConfig` + +- [ ] **Step 1: Add profile config structure** + +In `crates/owlry-core/src/config/mod.rs`, add: + +```rust +use std::collections::HashMap; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ProfileConfig { + pub modes: Vec, +} + +// Add to Config struct: +// pub profiles: HashMap, +``` + +- [ ] **Step 2: Update CLI args** + +In `crates/owlry/src/cli.rs`: +- Remove `--providers` / `-p` flag +- Add `--profile` flag: `Option` +- Keep `--mode` / `-m` flag +- Repurpose `-p` as short for `--prompt` (dmenu prompt text) + +```rust +#[derive(Debug)] +pub struct CliArgs { + pub mode: Option>, + pub profile: Option, + pub prompt: Option, // was --prompt, now also -p + pub command: Option, +} +``` + +- [ ] **Step 3: Resolve profile to modes** + +In `app.rs` or `main.rs`, resolve the profile: + +```rust +fn resolve_modes(args: &CliArgs, config: &Config) -> Option> { + if let Some(modes) = &args.mode { + return Some(modes.clone()); + } + if let Some(profile_name) = &args.profile { + if let Some(profile) = config.profiles.get(profile_name) { + return Some(profile.modes.clone()); + } + eprintln!("Unknown profile: {}", profile_name); + std::process::exit(1); + } + None // All providers +} +``` + +- [ ] **Step 4: Test profile resolution** + +Add config with profiles, verify `owlry --profile default` uses correct modes. + +- [ ] **Step 5: Commit** + +```bash +git add crates/owlry/src/cli.rs crates/owlry-core/src/config/mod.rs crates/owlry/src/app.rs +git commit -m "feat: add config profiles, remove --providers flag" +``` + +--- + +### Task 14: Create systemd service files + +**Files:** +- Create: `systemd/owlry-core.service` +- Create: `systemd/owlry-core.socket` + +- [ ] **Step 1: Create service unit** + +Create `systemd/owlry-core.service`: + +```ini +[Unit] +Description=Owlry application launcher daemon +Documentation=https://somegit.dev/Owlibou/owlry +After=graphical-session.target + +[Service] +Type=simple +ExecStart=/usr/bin/owlry-core +Restart=on-failure +RestartSec=3 +Environment=RUST_LOG=warn + +[Install] +WantedBy=default.target +``` + +- [ ] **Step 2: Create socket unit** + +Create `systemd/owlry-core.socket`: + +```ini +[Unit] +Description=Owlry launcher socket + +[Socket] +ListenStream=%t/owlry/owlry.sock +DirectoryMode=0700 + +[Install] +WantedBy=sockets.target +``` + +- [ ] **Step 3: Commit** + +```bash +git add systemd/ +git commit -m "feat: add systemd user service and socket units for owlry-core" +``` + +--- + +### Task 15: Update README and justfile for Phase 2 + +**Files:** +- Modify: `README.md` +- Modify: `justfile` + +- [ ] **Step 1: Update README** + +Rewrite README to reflect the new architecture: +- Explain client/daemon split +- Update installation instructions (install both binaries) +- Update usage examples (daemon startup, UI invocation, profiles) +- Update dmenu section (unchanged behavior, but clarify it bypasses daemon) +- Remove `--providers` references +- Add systemd setup instructions +- Add profile configuration examples +- Keep plugin usage section (will be updated in Phase 3) + +- [ ] **Step 2: Update justfile** + +Ensure all build/run/install/release targets reflect two binaries. Key changes: +- `just run` → runs the UI +- `just run-daemon` → runs the daemon +- `just install-local` → installs both binaries + systemd units +- `just release` → builds both in release mode +- Bump targets updated for owlry-core + +- [ ] **Step 3: Verify** + +Run: `just build` +Run: `just check` + +- [ ] **Step 4: Commit** + +```bash +git add README.md justfile +git commit -m "docs: update README and justfile for client/daemon architecture" +``` + +--- + +## Phase 3: Split Repos + +### Task 16: Create `owlry-plugins` workspace + +**Files:** +- Create: `owlry-plugins/Cargo.toml` +- Create: `owlry-plugins/justfile` +- Create: `owlry-plugins/README.md` +- Create: `owlry-plugins/.gitignore` + +- [ ] **Step 1: Create plugins workspace directory** + +```bash +mkdir -p /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins +``` + +- [ ] **Step 2: Initialize git repo** + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins +git init +git remote add origin gitea@somegit.dev:Owlibou/owlry-plugins.git +``` + +- [ ] **Step 3: Create workspace Cargo.toml** + +Create `owlry-plugins/Cargo.toml`: + +```toml +[workspace] +members = [ + "crates/owlry-plugin-calculator", + "crates/owlry-plugin-clipboard", + "crates/owlry-plugin-emoji", + "crates/owlry-plugin-bookmarks", + "crates/owlry-plugin-ssh", + "crates/owlry-plugin-scripts", + "crates/owlry-plugin-system", + "crates/owlry-plugin-websearch", + "crates/owlry-plugin-filesearch", + "crates/owlry-plugin-weather", + "crates/owlry-plugin-media", + "crates/owlry-plugin-pomodoro", + "crates/owlry-plugin-systemd", + "crates/owlry-lua", + "crates/owlry-rune", +] +resolver = "2" + +[workspace.package] +edition = "2024" +rust-version = "1.90" +license = "GPL-3.0-or-later" +repository = "https://somegit.dev/Owlibou/owlry-plugins" + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" +strip = true +opt-level = "z" +``` + +- [ ] **Step 4: Create .gitignore** + +Create `owlry-plugins/.gitignore`: + +``` +/target +**/*.rs.bk +Cargo.lock +``` + +Note: `Cargo.lock` is not committed for library/plugin workspaces — only for binaries. + +- [ ] **Step 5: Create README.md** + +Create `owlry-plugins/README.md` with plugin listing, build instructions, and link to plugin development docs. + +- [ ] **Step 6: Create justfile** + +Create `owlry-plugins/justfile`: + +```just +default: + @just --list + +build: + cargo build --workspace + +release: + cargo build --workspace --release + +check: + cargo check --workspace + cargo clippy --workspace + +test: + cargo test --workspace + +fmt: + cargo fmt --all + +# Build a specific plugin +plugin name: + cargo build -p owlry-plugin-{{name}} --release + +# Build all plugins +plugins: + cargo build --workspace --release + +# Show all crate versions +show-versions: + @for dir in crates/owlry-plugin-* crates/owlry-lua crates/owlry-rune; do \ + name=$(basename $dir); \ + version=$(grep '^version' $dir/Cargo.toml | head -1 | cut -d'"' -f2); \ + printf "%-35s %s\n" "$name" "$version"; \ + done + +# Bump a specific crate version +bump-crate crate new_version: + @cd crates/{{crate}} && \ + sed -i 's/^version = ".*"/version = "{{new_version}}"/' Cargo.toml + @echo "Bumped {{crate}} to {{new_version}}" + +# Bump all plugin crates +bump-all new_version: + @for dir in crates/owlry-plugin-* crates/owlry-lua crates/owlry-rune; do \ + sed -i 's/^version = ".*"/version = "{{new_version}}"/' $dir/Cargo.toml; \ + done + @echo "Bumped all crates to {{new_version}}" + +# Install all plugins locally +install-local: + just plugins + @for f in target/release/libowlry_plugin_*.so; do \ + sudo install -Dm755 "$f" /usr/lib/owlry/plugins/$(basename "$f"); \ + done + @for f in target/release/libowlry_lua.so target/release/libowlry_rune.so; do \ + [ -f "$f" ] && sudo install -Dm755 "$f" /usr/lib/owlry/runtimes/$(basename "$f"); \ + done + @echo "Installed all plugins and runtimes" + +# AUR operations +aur-update-pkg pkg: + @cd aur/{{pkg}} && updpkgsums + +aur-publish-pkg pkg: + @cd aur/{{pkg}} && makepkg --printsrcinfo > .SRCINFO && \ + git add PKGBUILD .SRCINFO && \ + git commit -m "Update {{pkg}}" && \ + git push + +aur-update-plugins: + @for dir in aur/owlry-plugin-*; do \ + pkg=$(basename $dir); \ + echo "Updating $pkg..."; \ + cd $dir && updpkgsums && cd ../..; \ + done +``` + +- [ ] **Step 7: Commit** + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins +git add Cargo.toml justfile README.md .gitignore +git commit -m "feat: scaffold owlry-plugins workspace" +``` + +--- + +### Task 17: Move plugin crates to `owlry-plugins` + +**Files:** +- Move: `owlry/crates/owlry-plugin-*` → `owlry-plugins/crates/owlry-plugin-*` +- Move: `owlry/crates/owlry-lua` → `owlry-plugins/crates/owlry-lua` +- Move: `owlry/crates/owlry-rune` → `owlry-plugins/crates/owlry-rune` +- Modify: each moved crate's `Cargo.toml` — change owlry-plugin-api dep + +- [ ] **Step 1: Copy plugin crates** + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou +mkdir -p owlry-plugins/crates + +# Copy all plugin crates +for dir in owlry/crates/owlry-plugin-*; do + cp -r "$dir" owlry-plugins/crates/ +done + +# Copy runtimes +cp -r owlry/crates/owlry-lua owlry-plugins/crates/ +cp -r owlry/crates/owlry-rune owlry-plugins/crates/ +``` + +- [ ] **Step 2: Update owlry-plugin-api dependency in all plugin Cargo.toml files** + +For each plugin and runtime crate, change: + +```toml +# Before (path dependency): +owlry-plugin-api = { path = "../owlry-plugin-api" } + +# After (git dependency): +owlry-plugin-api = { git = "https://somegit.dev/Owlibou/owlry.git", tag = "plugin-api-v0.5.0" } +``` + +Run this for all crates: + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins +for toml in crates/*/Cargo.toml; do + sed -i 's|owlry-plugin-api = { path = "../owlry-plugin-api" }|owlry-plugin-api = { git = "https://somegit.dev/Owlibou/owlry.git", tag = "plugin-api-v0.5.0" }|' "$toml" +done +``` + +Note: The tag `plugin-api-v0.5.0` must exist in the owlry repo. Create it after Phase 2 is pushed. During development, use `branch = "main"` instead: + +```toml +owlry-plugin-api = { git = "https://somegit.dev/Owlibou/owlry.git", branch = "main" } +``` + +- [ ] **Step 3: Update workspace.package.repository in root Cargo.toml** + +Already set to `https://somegit.dev/Owlibou/owlry-plugins` in Task 16. + +- [ ] **Step 4: Build plugins workspace** + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins +cargo build --workspace +``` + +Fix any compilation issues from the dependency change. + +- [ ] **Step 5: Commit** + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins +git add crates/ +git commit -m "feat: move all plugin and runtime crates from owlry" +``` + +--- + +### Task 18: Move AUR packages and docs to `owlry-plugins` + +**Files:** +- Move: `owlry/aur/owlry-plugin-*` → `owlry-plugins/aur/` +- Move: `owlry/aur/owlry-lua` → `owlry-plugins/aur/` +- Move: `owlry/aur/owlry-rune` → `owlry-plugins/aur/` +- Move: `owlry/docs/PLUGIN_DEVELOPMENT.md` → `owlry-plugins/docs/` +- Move: `owlry/docs/PLUGINS.md` → `owlry-plugins/docs/` + +- [ ] **Step 1: Copy AUR packages** + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou +mkdir -p owlry-plugins/aur + +for dir in owlry/aur/owlry-plugin-*; do + cp -r "$dir" owlry-plugins/aur/ +done +cp -r owlry/aur/owlry-lua owlry-plugins/aur/ +cp -r owlry/aur/owlry-rune owlry-plugins/aur/ +``` + +- [ ] **Step 2: Update PKGBUILD source URLs** + +All plugin PKGBUILDs currently point at the owlry repo. Update them to point at owlry-plugins: + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins +for pkgbuild in aur/*/PKGBUILD; do + # Update source URL to point to owlry-plugins repo + sed -i 's|somegit.dev/Owlibou/owlry/|somegit.dev/Owlibou/owlry-plugins/|g' "$pkgbuild" +done +``` + +Review each PKGBUILD to ensure build commands are correct (they should still work since the workspace structure under `crates/` is the same). + +- [ ] **Step 3: Copy plugin docs** + +```bash +mkdir -p owlry-plugins/docs +cp owlry/docs/PLUGIN_DEVELOPMENT.md owlry-plugins/docs/ +cp owlry/docs/PLUGINS.md owlry-plugins/docs/ +``` + +- [ ] **Step 4: Commit** + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins +git add aur/ docs/ +git commit -m "feat: move AUR packages and plugin docs from owlry" +``` + +--- + +### Task 19: Clean up core repo after plugin extraction + +**Files:** +- Modify: `owlry/Cargo.toml` — remove plugin/runtime workspace members +- Delete: `owlry/crates/owlry-plugin-*` (all 14 plugin crates) +- Delete: `owlry/crates/owlry-lua`, `owlry/crates/owlry-rune` +- Delete: `owlry/aur/owlry-plugin-*`, `owlry/aur/owlry-lua`, `owlry/aur/owlry-rune` +- Delete: `owlry/docs/PLUGIN_DEVELOPMENT.md`, `owlry/docs/PLUGINS.md` +- Modify: `owlry/justfile` — remove plugin build/bump/AUR targets + +- [ ] **Step 1: Update workspace members** + +In `owlry/Cargo.toml`, reduce members to: + +```toml +[workspace] +members = [ + "crates/owlry", + "crates/owlry-core", + "crates/owlry-plugin-api", +] +``` + +- [ ] **Step 2: Delete moved crates** + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry +rm -rf crates/owlry-plugin-* +rm -rf crates/owlry-lua +rm -rf crates/owlry-rune +``` + +- [ ] **Step 3: Delete moved AUR packages** + +```bash +rm -rf aur/owlry-plugin-* +rm -rf aur/owlry-lua +rm -rf aur/owlry-rune +``` + +- [ ] **Step 4: Delete moved docs** + +```bash +rm -f docs/PLUGIN_DEVELOPMENT.md docs/PLUGINS.md +``` + +- [ ] **Step 5: Clean up justfile** + +Remove plugin-specific targets from the justfile: +- Remove: `plugin`, `plugins`, `bump-plugins`, `bump-crate` (for plugins) +- Remove: plugin AUR targets (`aur-update-plugins`, `aur-publish-plugins`, etc.) +- Keep: core build, bump, AUR targets for owlry, owlry-core, owlry-plugin-api + +- [ ] **Step 6: Add owlry-core AUR PKGBUILD** + +Create `aur/owlry-core/PKGBUILD`: + +```bash +# Maintainer: ... +pkgname=owlry-core +pkgver=0.5.0 +pkgrel=1 +pkgdesc='Core daemon for the Owlry application launcher' +arch=('x86_64') +url='https://somegit.dev/Owlibou/owlry' +license=('GPL-3.0-or-later') +depends=('gcc-libs') +makedepends=('cargo' 'git') +source=("$pkgname-$pkgver::git+https://somegit.dev/Owlibou/owlry.git#tag=v$pkgver") +sha256sums=('SKIP') + +build() { + cd "$pkgname-$pkgver" + cargo build -p owlry-core --frozen --release +} + +package() { + cd "$pkgname-$pkgver" + install -Dm755 "target/release/owlry-core" "$pkgdir/usr/bin/owlry-core" + install -Dm644 "systemd/owlry-core.service" "$pkgdir/usr/lib/systemd/user/owlry-core.service" + install -Dm644 "systemd/owlry-core.socket" "$pkgdir/usr/lib/systemd/user/owlry-core.socket" + + # Create plugin directories + install -dm755 "$pkgdir/usr/lib/owlry/plugins" + install -dm755 "$pkgdir/usr/lib/owlry/runtimes" +} +``` + +- [ ] **Step 7: Update meta-package PKGBUILDs** + +Update `aur/owlry-meta-*/PKGBUILD` to add `owlry-core` as a dependency. The meta-essentials should depend on both `owlry` and `owlry-core`. + +- [ ] **Step 8: Build and verify core workspace** + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry +cargo build --workspace +cargo test --workspace +``` + +- [ ] **Step 9: Commit** + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry +git add -A +git commit -m "refactor: remove plugins and runtimes from core repo (moved to owlry-plugins)" +``` + +--- + +### Task 20: Update CLAUDE.md for new structure + +**Files:** +- Modify: `owlry/CLAUDE.md` + +- [ ] **Step 1: Update CLAUDE.md** + +Rewrite CLAUDE.md to reflect: +- New workspace structure (3 crates: owlry, owlry-core, owlry-plugin-api) +- Daemon architecture and IPC +- Build commands for daemon and UI +- Updated justfile targets +- Reference to owlry-plugins repo for plugin development +- Updated AUR packaging info +- New CLI flags (--profile, removal of --providers) +- Systemd integration + +- [ ] **Step 2: Create CLAUDE.md in owlry-plugins** + +Create `owlry-plugins/CLAUDE.md` with: +- Plugin workspace structure +- Build commands +- Plugin API dependency info +- AUR packaging workflow +- How to add new plugins + +- [ ] **Step 3: Commit both** + +```bash +# Core repo +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry +git add CLAUDE.md +git commit -m "docs: update CLAUDE.md for new architecture" + +# Plugins repo +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins +git add CLAUDE.md +git commit -m "docs: add CLAUDE.md for plugins workspace" +``` + +--- + +## Phase 4: Polish & Verify + +### Task 21: End-to-end verification + +**Files:** None (testing only) + +- [ ] **Step 1: Build both workspaces** + +```bash +# Core +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry +cargo build --workspace +cargo test --workspace +cargo clippy --workspace + +# Plugins +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins +cargo build --workspace +cargo test --workspace +cargo clippy --workspace +``` + +- [ ] **Step 2: Install locally and test** + +```bash +# Install core +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry +just install-local + +# Install plugins +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins +just install-local +``` + +- [ ] **Step 3: Test daemon lifecycle** + +```bash +# Start daemon +owlry-core & +ls $XDG_RUNTIME_DIR/owlry/owlry.sock # Socket should exist + +# Launch UI +owlry # Should connect to daemon, show all providers +owlry -m app,cmd # Should filter to app+cmd +owlry --profile default # Should use profile from config + +# Test toggle +owlry & # Open UI +owlry # Should close existing UI + +# Test dmenu (bypasses daemon) +echo -e "option1\noption2" | owlry -m dmenu -p "Pick:" + +# Stop daemon +kill %1 +``` + +- [ ] **Step 4: Test systemd integration** + +```bash +# Copy service files +mkdir -p ~/.config/systemd/user +cp /usr/lib/systemd/user/owlry-core.service ~/.config/systemd/user/ 2>/dev/null +cp /usr/lib/systemd/user/owlry-core.socket ~/.config/systemd/user/ 2>/dev/null +systemctl --user daemon-reload + +# Test service start +systemctl --user start owlry-core +systemctl --user status owlry-core +owlry # Should connect to systemd-managed daemon + +# Test socket activation +systemctl --user stop owlry-core +systemctl --user start owlry-core.socket +owlry # Should trigger socket activation + +# Cleanup +systemctl --user stop owlry-core owlry-core.socket +``` + +- [ ] **Step 5: Verify AUR PKGBUILDs build** + +```bash +# Test core PKGBUILD +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry/aur/owlry +makepkg -s + +cd ../owlry-core +makepkg -s + +# Test a plugin PKGBUILD +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins/aur/owlry-plugin-calculator +makepkg -s +``` + +- [ ] **Step 6: Document any issues found** + +If any tests fail, note the issue and fix it before proceeding. + +--- + +### Task 22: Final cleanup and formatting + +**Files:** +- All modified files across both repos + +- [ ] **Step 1: Format all code** + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry +cargo fmt --all + +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins +cargo fmt --all +``` + +- [ ] **Step 2: Run final clippy** + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry +cargo clippy --workspace -- -D warnings + +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins +cargo clippy --workspace -- -D warnings +``` + +- [ ] **Step 3: Review and clean up any leftover references** + +Search for stale references in both repos: + +```bash +# In core repo: any references to removed plugin crates? +grep -r "owlry-plugin-" crates/ --include="*.rs" --include="*.toml" + +# In plugins repo: any path deps to owlry-plugin-api? +grep -r 'path = "../owlry-plugin-api"' crates/ +``` + +- [ ] **Step 4: Final commits** + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry +git add -A +git commit -m "chore: final cleanup and formatting" + +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins +git add -A +git commit -m "chore: final cleanup and formatting" +``` + +--- + +### Task 23: Tag releases + +**Files:** None (git operations only) + +- [ ] **Step 1: Tag core repo** + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry +git tag -a v0.5.0 -m "feat: client/daemon architecture split" +git tag -a plugin-api-v0.5.0 -m "owlry-plugin-api v0.5.0" +``` + +- [ ] **Step 2: Push core repo** + +```bash +git push origin main --tags +``` + +- [ ] **Step 3: Update plugins repo to use tagged plugin-api** + +Update all plugin `Cargo.toml` files from `branch = "main"` to `tag = "plugin-api-v0.5.0"`: + +```bash +cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins +for toml in crates/*/Cargo.toml; do + sed -i 's|branch = "main"|tag = "plugin-api-v0.5.0"|' "$toml" +done +cargo update +cargo build --workspace +git add -A +git commit -m "chore: pin owlry-plugin-api to tagged release v0.5.0" +``` + +- [ ] **Step 4: Tag and push plugins repo** + +```bash +git tag -a v0.5.0 -m "Initial release: plugins split from owlry core" +git push origin main --tags +``` + +Note: User confirmed AUR publishing happens later, after verification. Do NOT push to AUR git repos in this task. diff --git a/docs/superpowers/specs/2026-03-26-architecture-split-design.md b/docs/superpowers/specs/2026-03-26-architecture-split-design.md new file mode 100644 index 0000000..677c3a2 --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-architecture-split-design.md @@ -0,0 +1,458 @@ +# Owlry Architecture Split — Design Spec + +**Date:** 2026-03-26 +**Status:** Draft +**Goal:** Split owlry into two repos with a client/daemon architecture, enabling independent release cadence and clean architectural separation. + +--- + +## 1. Overview + +Currently owlry is a monorepo with 18 crates that all share version `0.4.10`. Changing any single package requires bumping all versions and rebuilding every AUR package from the same source. The architecture also couples the GTK UI directly to plugin loading and provider management, meaning the UI blocks during initialization. + +This redesign: +- Splits into two repositories (core + plugins) +- Extracts backend logic into `owlry-core` daemon +- Makes `owlry` a thin GTK4 client communicating over Unix socket IPC +- Enables independent versioning and release cadence per package +- Makes third-party plugin development straightforward + +--- + +## 2. Repository Layout + +### Repo 1 — `owlry/` (core, changes infrequently) + +``` +owlry/ +├── Cargo.toml # Workspace: owlry, owlry-core, owlry-plugin-api +├── crates/ +│ ├── owlry/ # GTK4 frontend binary (thin UI client) +│ │ └── src/ +│ │ ├── main.rs # Entry point, CLI parsing +│ │ ├── app.rs # GTK Application setup, CSS/theme loading +│ │ ├── cli.rs # Argument parsing (--mode, --profile, --dmenu) +│ │ ├── client.rs # IPC client (connects to daemon socket) +│ │ ├── ui/ +│ │ │ ├── mod.rs +│ │ │ ├── main_window.rs # GTK window, Layer Shell overlay +│ │ │ ├── result_row.rs # Result rendering +│ │ │ └── submenu.rs # Submenu rendering +│ │ ├── theme.rs # CSS loading priority +│ │ └── providers/ +│ │ └── dmenu.rs # Self-contained dmenu mode (bypasses daemon) +│ ├── owlry-core/ # Daemon binary +│ │ └── src/ +│ │ ├── main.rs # Daemon entry point +│ │ ├── server.rs # Unix socket IPC server +│ │ ├── config/ +│ │ │ └── mod.rs # Config loading (~/.config/owlry/config.toml) +│ │ ├── data/ +│ │ │ ├── mod.rs +│ │ │ └── frecency.rs # FrecencyStore +│ │ ├── filter.rs # ProviderFilter, prefix/query parsing +│ │ ├── providers/ +│ │ │ ├── mod.rs # ProviderManager +│ │ │ ├── application.rs # Desktop application provider +│ │ │ ├── command.rs # PATH command provider +│ │ │ ├── native_provider.rs # Bridge: native plugin → provider +│ │ │ └── lua_provider.rs # Bridge: lua/rune runtime → provider +│ │ ├── plugins/ +│ │ │ ├── mod.rs +│ │ │ ├── native_loader.rs # Loads .so plugins +│ │ │ ├── runtime_loader.rs # Loads lua/rune runtimes +│ │ │ ├── loader.rs +│ │ │ ├── manifest.rs +│ │ │ ├── registry.rs +│ │ │ ├── commands.rs +│ │ │ ├── error.rs +│ │ │ ├── runtime.rs +│ │ │ └── api/ # Lua/Rune scripting API surface +│ │ │ ├── mod.rs +│ │ │ ├── provider.rs +│ │ │ ├── process.rs +│ │ │ ├── cache.rs +│ │ │ ├── math.rs +│ │ │ ├── action.rs +│ │ │ ├── theme.rs +│ │ │ ├── http.rs +│ │ │ ├── hook.rs +│ │ │ └── utils.rs +│ │ ├── notify.rs # Notification dispatch +│ │ └── paths.rs # XDG path helpers +│ └── owlry-plugin-api/ # ABI-stable plugin interface (published crate) +│ └── src/ +│ └── lib.rs # PluginVTable, PluginInfo, ProviderInfo, etc. +├── aur/ +│ ├── owlry/ # UI package +│ ├── owlry-core/ # Daemon package (new) +│ ├── owlry-meta-essentials/ +│ ├── owlry-meta-full/ +│ ├── owlry-meta-tools/ +│ └── owlry-meta-widgets/ +├── resources/ # CSS, base themes, icons +├── systemd/ +│ ├── owlry-core.service # Systemd user service +│ └── owlry-core.socket # Socket activation (optional) +├── justfile +├── README.md +└── docs/ +``` + +### Repo 2 — `owlry-plugins/` (plugins, releases independently) + +``` +owlry-plugins/ +├── Cargo.toml # Workspace: all plugins + runtimes +├── crates/ +│ ├── owlry-plugin-calculator/ +│ ├── owlry-plugin-clipboard/ +│ ├── owlry-plugin-emoji/ +│ ├── owlry-plugin-bookmarks/ +│ ├── owlry-plugin-ssh/ +│ ├── owlry-plugin-scripts/ +│ ├── owlry-plugin-system/ +│ ├── owlry-plugin-websearch/ +│ ├── owlry-plugin-filesearch/ +│ ├── owlry-plugin-weather/ +│ ├── owlry-plugin-media/ +│ ├── owlry-plugin-pomodoro/ +│ ├── owlry-plugin-systemd/ +│ ├── owlry-lua/ # Lua runtime (cdylib) +│ └── owlry-rune/ # Rune runtime (cdylib) +├── aur/ +│ ├── owlry-plugin-*/ # Individual plugin PKGBUILDs +│ ├── owlry-lua/ +│ └── owlry-rune/ +├── justfile +├── README.md +└── docs/ + ├── PLUGIN_DEVELOPMENT.md + └── PLUGINS.md +``` + +--- + +## 3. Daemon Architecture (`owlry-core`) + +### Responsibilities + +- Load and manage all native plugins from: + - `/usr/lib/owlry/plugins/*.so` (system-installed) + - `~/.local/lib/owlry/plugins/*.so` (user-installed native) +- Load script runtimes from: + - `/usr/lib/owlry/runtimes/*.so` (system) + - `~/.config/owlry/plugins/` (user lua/rune scripts) +- Maintain `ProviderManager` with all providers initialized +- Own `FrecencyStore` (persists across UI open/close cycles) +- Load and watch config from `~/.config/owlry/config.toml` +- Listen on Unix socket at `$XDG_RUNTIME_DIR/owlry/owlry.sock` + +### Systemd Integration + +```ini +# owlry-core.service +[Unit] +Description=Owlry application launcher daemon +Documentation=https://somegit.dev/Owlibou/owlry + +[Service] +Type=simple +ExecStart=/usr/bin/owlry-core +Restart=on-failure +Environment=RUST_LOG=warn + +[Install] +WantedBy=default.target +``` + +```ini +# owlry-core.socket (optional socket activation) +[Unit] +Description=Owlry launcher socket + +[Socket] +ListenStream=%t/owlry/owlry.sock +DirectoryMode=0700 + +[Install] +WantedBy=sockets.target +``` + +Users can either: +- `exec-once owlry-core` in compositor config +- `systemctl --user enable --now owlry-core.service` +- Rely on socket activation (daemon starts on first UI connection) + +--- + +## 4. IPC Protocol + +JSON messages over Unix domain socket, newline-delimited (`\n`). Each message is a single JSON object on one line. + +### Client → Server + +```json +{"type": "query", "text": "fire", "modes": ["app", "cmd"]} +``` +Query providers. `modes` is optional — omit to query all. Server streams back results. + +```json +{"type": "launch", "item_id": "firefox.desktop", "provider": "app"} +``` +Notify daemon that an item was launched (updates frecency). + +```json +{"type": "providers"} +``` +List all available providers (for UI tab rendering). + +```json +{"type": "refresh", "provider": "clipboard"} +``` +Force a specific provider to refresh its data. + +```json +{"type": "toggle"} +``` +Used for toggle behavior — if UI is already open, close it. + +```json +{"type": "submenu", "plugin_id": "systemd", "data": "docker.service"} +``` +Request submenu items for a plugin. + +### Server → Client + +```json +{"type": "results", "items": [{"id": "firefox.desktop", "title": "Firefox", "description": "Web Browser", "icon": "firefox", "provider": "app", "score": 95}]} +``` + +```json +{"type": "providers", "list": [{"id": "app", "name": "Applications", "prefix": ":app", "icon": "application-x-executable", "position": "normal"}]} +``` + +```json +{"type": "submenu_items", "items": [...]} +``` + +```json +{"type": "error", "message": "..."} +``` + +```json +{"type": "ack"} +``` + +--- + +## 5. UI Client (`owlry`) + +### CLI Interface + +``` +owlry # Open with all providers +owlry -m app,cmd,calc # Open with specific modes +owlry --profile default # Open with named profile from config +owlry -m dmenu -p "Pick:" # dmenu mode (bypasses daemon, reads stdin) +``` + +**Removed flags:** +- `-p` / `--providers` — removed, was confusing overlap with `-m` + +Note: `-p` is repurposed as the dmenu prompt flag (short for `--prompt`), matching dmenu/rofi convention. + +### Profiles (in `config.toml`) + +```toml +[profiles.default] +modes = ["app", "cmd", "calc"] + +[profiles.utils] +modes = ["clip", "emoji", "ssh", "sys"] + +[profiles.dev] +modes = ["app", "cmd", "ssh", "file"] +``` + +Usage: `owlry --profile utils` + +### Toggle Behavior + +When `owlry` is invoked while already open: +1. Client connects to daemon socket +2. Sends `{"type": "toggle"}` +3. If an instance is already showing, it closes +4. If no instance is showing, opens normally + +Implementation: the daemon tracks whether a UI client is currently connected and visible. + +### Auto-Start Fallback + +If the UI tries to connect to the daemon socket and it doesn't exist: +1. Attempt to start via systemd: `systemctl --user start owlry-core` +2. If systemd activation fails, fork `owlry-core` directly +3. Retry connection with brief backoff (100ms, 200ms, 400ms — 3 attempts max) +4. If still unreachable, exit with clear error message + +### dmenu Mode + +Completely self-contained in the UI binary: +- Reads items from stdin +- Renders GTK picker +- Prints selected item to stdout +- No daemon connection, no frecency, no plugins +- `-p "prompt"` sets the prompt text + +--- + +## 6. Plugin API Decoupling + +### `owlry-plugin-api` as Published Crate + +- Published to crates.io (or referenced as git dep from core repo) +- Versioned independently — bumped only when ABI changes +- Current `API_VERSION = 3` continues +- Third-party plugin authors: `cargo add owlry-plugin-api`, implement `owlry_plugin_vtable`, build as cdylib + +### Plugin Dependencies in `owlry-plugins` Repo + +Each plugin's `Cargo.toml`: +```toml +[dependencies] +owlry-plugin-api = "0.5" # from crates.io +# or +owlry-plugin-api = { git = "https://somegit.dev/Owlibou/owlry.git", tag = "plugin-api-v0.5.0" } +``` + +No path dependencies between repos. + +### Plugin Discovery (daemon) + +Auto-discovers all `.so` files in plugin directories. No explicit enable list needed. + +Disabling in `config.toml`: +```toml +[plugins] +disabled = ["pomodoro", "media"] +``` + +--- + +## 7. Versioning Strategy + +### Core Repo (`owlry`) + +| Crate | Versioning | When to bump | +|-------|-----------|-------------| +| `owlry-plugin-api` | Independent semver | ABI changes only (rare) | +| `owlry-core` | Independent semver | Daemon logic, IPC, provider changes | +| `owlry` | Independent semver | UI changes, CLI changes | + +These can share a workspace version if convenient, but are not required to stay in lockstep. + +### Plugins Repo (`owlry-plugins`) + +| Crate | Versioning | When to bump | +|-------|-----------|-------------| +| Each plugin | Independent semver | That plugin's code changes | +| `owlry-lua` | Independent semver | Lua runtime changes | +| `owlry-rune` | Independent semver | Rune runtime changes | + +A calculator bugfix bumps only `owlry-plugin-calculator`. Nothing else changes. + +### AUR Packages + +- Core PKGBUILDs source from `owlry` repo tags +- Plugin PKGBUILDs source from `owlry-plugins` repo, each with their own tag +- Meta-packages stay at `1.0.0`, only `pkgrel` changes when included packages update + +--- + +## 8. Dependency Refresh + +Before starting implementation, update all external crate dependencies to latest stable versions: +- Run `cargo update` to refresh `Cargo.lock` +- Review and bump version constraints in `Cargo.toml` files where appropriate (e.g., `gtk4`, `abi_stable`, `reqwest`, `mlua`, `rune`, etc.) +- Verify the workspace still builds and tests pass after updates + +--- + +## 9. Migration Phases + +### Phase 0 — Dependency Refresh +- Update all external crates to latest stable versions +- Verify build + tests pass +- Commit + +### Phase 1 — Extract `owlry-core` Crate (library, no IPC yet) +- Create `crates/owlry-core/` with its own `Cargo.toml` +- Move from `crates/owlry/src/` into `crates/owlry-core/src/`: + - `config/` (config loading) + - `data/` (frecency) + - `filter.rs` (provider filter, prefix parsing) + - `providers/` — `mod.rs`, `application.rs`, `command.rs`, `native_provider.rs`, `lua_provider.rs` + - `plugins/` — entire directory (native_loader, runtime_loader, manifest, registry, commands, error, runtime, api/) + - `notify.rs` + - `paths.rs` +- `owlry` depends on `owlry-core` as a library (path dep) +- Launcher still works as before — no behavioral change +- Update justfile for new crate +- Update README to reflect new structure +- Commit + +### Phase 2 — Add IPC Layer (daemon + client) +- Add `server.rs` to `owlry-core` — Unix socket listener, JSON protocol +- Add `main.rs` to `owlry-core` — daemon entry point +- Add `[[bin]]` target to `owlry-core` alongside the library (`lib` + `bin` in same crate) +- Add `client.rs` to `owlry` — connects to daemon, sends queries +- Wire up `owlry` UI to use IPC client instead of direct library calls +- Implement toggle behavior +- Implement auto-start fallback +- Implement profiles in config +- Add systemd service + socket files +- dmenu mode stays self-contained in `owlry` +- Remove `-p` provider flag, repurpose as `--prompt` for dmenu +- Update justfile +- Update README +- Commit + +### Phase 3 — Split Repos +- Create `owlry-plugins/` directory with its own workspace +- Move all `crates/owlry-plugin-*/` into `owlry-plugins/crates/` +- Move `crates/owlry-lua/` and `crates/owlry-rune/` into `owlry-plugins/crates/` +- Update plugin `Cargo.toml` files: path dep → git/crates.io dep for `owlry-plugin-api` +- Move plugin AUR PKGBUILDs into `owlry-plugins/aur/` +- Move runtime AUR PKGBUILDs into `owlry-plugins/aur/` +- Create `owlry-plugins/justfile` +- Move `docs/PLUGIN_DEVELOPMENT.md` and `docs/PLUGINS.md` into `owlry-plugins/docs/` +- Create `owlry-plugins/README.md` +- Update core `owlry/justfile` (remove plugin-related targets) +- Update core `owlry/README.md` +- Add `owlry-core` AUR PKGBUILD +- Update meta-package PKGBUILDs to reflect new source structure +- Update `CLAUDE.md` +- Commit both repos + +### Phase 4 — Polish & Verify +- Verify all AUR PKGBUILDs build correctly +- Verify `owlry-core` daemon starts and responds to IPC +- Verify `owlry` UI connects, queries, renders, and launches +- Verify dmenu mode still works standalone +- Verify toggle behavior +- Verify profile loading +- Verify auto-start fallback +- Verify systemd service and socket activation +- Verify plugin loading from both system and user paths +- Clean up any leftover references to old structure +- Final README review + +--- + +## 10. Out of Scope + +- Publishing to crates.io (can be done later) +- Pushing updated PKGBUILDs to AUR git repos (done after verification) +- Alternative frontends (TUI, etc.) — the architecture enables this but it's future work +- Plugin hot-reloading in the daemon +- Multi-client support (only one UI at a time for now) From 9db3be6fdc8725fb6f140471f7f29b6952729965 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 11:46:02 +0100 Subject: [PATCH 02/20] chore: update all dependencies to latest stable Major version bumps: - reqwest: 0.12 -> 0.13 (rustls-tls feature renamed to rustls) - mlua: 0.10 -> 0.11 - freedesktop-desktop-entry: 0.7 -> 0.8 - rusqlite: 0.32 -> 0.39 Cargo.lock refreshed with latest semver-compatible versions across all transitive dependencies. Note: gtk4 0.11 / glib-build-tools 0.22 skipped (requires Rust 1.92, current toolchain is 1.91). --- Cargo.lock | 1334 ++++++++++++++-------- crates/owlry-lua/Cargo.toml | 4 +- crates/owlry-plugin-bookmarks/Cargo.toml | 2 +- crates/owlry-plugin-weather/Cargo.toml | 2 +- crates/owlry-rune/Cargo.toml | 2 +- crates/owlry/Cargo.toml | 6 +- 6 files changed, 872 insertions(+), 478 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 44de86e..ed4b218 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,9 +88,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -103,15 +103,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -138,9 +138,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "as_derive_utils" @@ -180,22 +180,21 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.36" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ec5f6c2f8bc326c994cb9e241cc257ddaba9afa8555a43cffbb5dd86efaa37" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" dependencies = [ "compression-codecs", "compression-core", - "futures-core", "pin-project-lite", "tokio", ] [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -260,7 +259,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -295,7 +294,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -310,6 +309,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base64" version = "0.22.1" @@ -318,9 +339,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block" @@ -363,15 +384,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cairo-rs" @@ -398,19 +419,27 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.51" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] [[package]] -name = "cfg-expr" -version = "0.20.5" +name = "cesu8" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21be0e1ce6cdb2ee7fff840f922fb04ead349e5cfb1e750b769132d44ce04720" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-expr" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" dependencies = [ "smallvec", "target-lexicon", @@ -430,9 +459,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -444,9 +473,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.53" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -454,9 +483,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -466,21 +495,30 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] [[package]] name = "codespan-reporting" @@ -494,15 +532,25 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] [[package]] name = "compression-codecs" -version = "0.4.35" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0f7ac3e5b97fdce45e8922fb05cae2c37f7bbd63d30dd94821dacfd8f3f2bf2" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" dependencies = [ "compression-core", "flate2", @@ -543,6 +591,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -596,9 +654,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", ] @@ -626,9 +684,9 @@ dependencies = [ [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags", "objc2", @@ -642,9 +700,15 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.15.0" @@ -684,30 +748,24 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] name = "env_filter" -version = "0.1.4" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", ] -[[package]] -name = "env_home" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" - [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "anstream", "anstyle", @@ -724,9 +782,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" dependencies = [ "serde", "serde_core", @@ -794,15 +852,15 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.6" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -815,19 +873,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "foreign-types" -version = "0.3.2" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "foreign-types-shared" -version = "0.1.1" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "form_urlencoded" @@ -840,24 +895,30 @@ dependencies = [ [[package]] name = "freedesktop-desktop-entry" -version = "0.7.19" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528df05c8ed0bfd569c7018914ba1995be2a133ba9ead17628ddb0ff94b86331" +checksum = "28273c5c6b97a5f07724f6652f064c0c7f637f9aa5e7c09c83bc3bc4ad4ea245" dependencies = [ "bstr", "gettext-rs", "log", "memchr", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicase", "xdg", ] [[package]] -name = "futures-channel" -version = "0.3.31" +name = "fs_extra" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -865,15 +926,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -882,9 +943,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -901,32 +962,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", @@ -935,7 +996,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1032,9 +1092,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -1052,11 +1112,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "gettext-rs" version = "0.7.7" @@ -1218,7 +1291,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -1231,7 +1304,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -1388,7 +1461,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -1412,9 +1485,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", @@ -1431,11 +1504,11 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "ahash", + "foldhash 0.1.5", ] [[package]] @@ -1443,14 +1516,17 @@ 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.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.16.1", ] [[package]] @@ -1549,32 +1625,15 @@ dependencies = [ "webpki-roots", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -1593,9 +1652,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1696,6 +1755,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -1719,25 +1784,27 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" dependencies = [ "memchr", "serde", @@ -1751,15 +1818,15 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.17" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a87d9b8105c23642f50cbbae03d1f75d8422c5cb98ce7ee9271f7ff7505be6b8" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" dependencies = [ "jiff-static", "log", @@ -1770,20 +1837,74 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.17" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b787bebb543f8969132630c51fd0afab173a86c6abae56ff3b9e5e3e3f9f6e58" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", ] [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -1802,10 +1923,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "libc" -version = "0.2.178" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libloading" @@ -1829,19 +1956,18 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags", "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", @@ -1850,9 +1976,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -1909,18 +2035,18 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lua-src" -version = "547.0.0" +version = "550.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edaf29e3517b49b8b746701e5648ccb5785cde1c119062cbabbc5d5cd115e42" +checksum = "e836dc8ae16806c9bdcf42003a88da27d163433e3f9684c52f0301258004a4fb" dependencies = [ "cc", ] [[package]] name = "luajit-src" -version = "210.5.12+a4f56a4" +version = "210.6.6+707c12b" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a8e7962a5368d5f264d045a5a255e90f9aa3fc1941ae15a8d2940d42cac671" +checksum = "a86cc925d4053d0526ae7f5bc765dbd0d7a5d1a63d43974f4966cb349ca63295" dependencies = [ "cc", "which", @@ -1928,9 +2054,9 @@ dependencies = [ [[package]] name = "mac-notification-sys" -version = "0.6.9" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" dependencies = [ "cc", "objc2", @@ -1958,9 +2084,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memoffset" @@ -2010,13 +2136,14 @@ dependencies = [ [[package]] name = "mlua" -version = "0.10.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1f5f8fbebc7db5f671671134b9321c4b9aa9adeafccfd9a8c020ae45c6a35d0" +checksum = "ccd36acfa49ce6ee56d1307a061dd302c564eee757e6e4cd67eb4f7204846fab" dependencies = [ "bstr", "either", "erased-serde", + "libc", "mlua-sys", "num-traits", "parking_lot", @@ -2028,12 +2155,13 @@ dependencies = [ [[package]] name = "mlua-sys" -version = "0.6.8" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380c1f7e2099cafcf40e51d3a9f20a346977587aa4d012eae1f043149a728a93" +checksum = "0f1c3a7fc7580227ece249fd90aa2fa3b39eb2b49d3aec5e103b3e85f2c3dfc8" dependencies = [ "cc", "cfg-if", + "libc", "lua-src", "luajit-src", "pkg-config", @@ -2068,7 +2196,7 @@ checksum = "e7427c9aa85c882cd4dbe712d2fcdc511db05d595f7787e6747c90cd7d67efc4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -2077,37 +2205,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" dependencies = [ - "getrandom 0.2.16", -] - -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", + "getrandom 0.2.17", ] [[package]] @@ -2118,9 +2216,9 @@ checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce" [[package]] name = "notify-rust" -version = "4.11.7" +version = "4.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" +checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" dependencies = [ "futures-lite", "log", @@ -2174,9 +2272,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -2240,9 +2338,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -2288,9 +2386,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" dependencies = [ "critical-section", "portable-atomic", @@ -2302,49 +2400,11 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl" -version = "0.10.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "option-ext" @@ -2391,12 +2451,12 @@ dependencies = [ "mlua", "notify-rust", "owlry-plugin-api", - "reqwest", + "reqwest 0.13.2", "semver", "serde", "serde_json", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "toml 0.8.23", ] @@ -2410,7 +2470,7 @@ dependencies = [ "meval", "mlua", "owlry-plugin-api", - "reqwest", + "reqwest 0.13.2", "semver", "serde", "serde_json", @@ -2533,7 +2593,7 @@ dependencies = [ "abi_stable", "dirs", "owlry-plugin-api", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "toml 0.8.23", @@ -2556,7 +2616,7 @@ dependencies = [ "env_logger", "log", "owlry-plugin-api", - "reqwest", + "reqwest 0.13.2", "rune", "rune-modules", "semver", @@ -2633,29 +2693,29 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -2665,9 +2725,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -2696,15 +2756,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" dependencies = [ "portable-atomic", ] @@ -2734,19 +2794,29 @@ dependencies = [ ] [[package]] -name = "proc-macro-crate" -version = "3.4.0" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "toml_edit 0.23.10+spec-1.0.0", + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.8+spec-1.1.0", ] [[package]] name = "proc-macro2" -version = "1.0.104" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -2774,7 +2844,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -2782,10 +2852,11 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -2795,7 +2866,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -2817,9 +2888,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -2830,6 +2901,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.9.2" @@ -2852,9 +2929,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -2874,16 +2951,16 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -2893,9 +2970,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -2904,9 +2981,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "repr_offset" @@ -2925,22 +3002,15 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", - "encoding_rs", - "futures-channel", "futures-core", - "futures-util", - "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -2951,7 +3021,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tower", "tower-http", @@ -2963,6 +3032,48 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -2971,12 +3082,22 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", ] +[[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 = "rune" version = "0.14.1" @@ -3023,7 +3144,7 @@ checksum = "382b14f6d8e65e9cfec789e85125f3e1d758b2756705739e39ccf06fd249a564" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -3047,7 +3168,7 @@ dependencies = [ "proc-macro2", "quote", "rune-core", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -3058,7 +3179,7 @@ checksum = "5f4ef5dc3546042989f4abc70d6b9f707a539d5cbb5cb2fb167f8fbe891e1b64" dependencies = [ "base64", "nanorand", - "reqwest", + "reqwest 0.12.28", "rune", "serde_json", "tokio", @@ -3082,14 +3203,14 @@ checksum = "12387f96a3e131ce5be8c5668e55f1581dbc6635555d77aa07ab509fd13562bb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] name = "rusqlite" -version = "0.32.1" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ "bitflags", "fallible-iterator", @@ -3097,6 +3218,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] @@ -3116,9 +3238,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", @@ -3129,10 +3251,11 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -3142,21 +3265,61 @@ dependencies = [ ] [[package]] -name = "rustls-pki-types" -version = "1.13.2" +name = "rustls-native-certs" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", ] [[package]] -name = "rustls-webpki" -version = "0.103.8" +name = "rustls-platform-verifier" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3170,15 +3333,24 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +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.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -3197,12 +3369,12 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3210,9 +3382,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -3261,14 +3433,14 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.148" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", @@ -3285,7 +3457,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -3299,9 +3471,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" dependencies = [ "serde_core", ] @@ -3357,9 +3529,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -3369,12 +3541,24 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", ] [[package]] @@ -3383,12 +3567,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "strsim" version = "0.11.1" @@ -3414,9 +3592,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -3440,7 +3618,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -3451,12 +3629,12 @@ checksum = "00c99c9cda412afe293a6b962af651b4594161ba88c1affe7ef66459ea040a06" [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3479,7 +3657,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml 0.9.10+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", "version-compare", ] @@ -3496,7 +3674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ "quick-xml", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows", "windows-version", ] @@ -3509,12 +3687,12 @@ checksum = "83176759e9416cf81ee66cb6508dbfe9c96f20b8b56265a39917551c23c70964" [[package]] name = "tempfile" -version = "3.24.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3540,11 +3718,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -3555,18 +3733,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -3580,22 +3758,22 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "tinystr" @@ -3609,9 +3787,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -3624,9 +3802,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", @@ -3637,16 +3815,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -3659,9 +3827,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -3684,17 +3852,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.10+spec-1.1.0" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.0.4", + "serde_spanned 1.1.0", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -3715,6 +3883,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -3726,28 +3903,28 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap", - "toml_datetime 0.7.5+spec-1.1.0", + "toml_datetime 1.1.0+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "winnow", + "winnow 1.0.0", ] [[package]] @@ -3758,15 +3935,15 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -3831,7 +4008,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -3857,9 +4034,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -3920,26 +4097,26 @@ checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71" [[package]] name = "uds_windows" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "winapi", + "windows-sys 0.61.2", ] [[package]] name = "unicase" -version = "2.8.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-width" @@ -3947,6 +4124,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -3955,9 +4138,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -3979,9 +4162,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.19.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ "js-sys", "serde_core", @@ -4012,6 +4195,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4029,18 +4222,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -4051,11 +4253,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -4064,9 +4267,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4074,31 +4277,65 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] [[package]] -name = "web-sys" -version = "0.3.83" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", @@ -4115,24 +4352,30 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "1.0.4" +name = "webpki-root-certs" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] [[package]] name = "which" -version = "7.0.3" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" dependencies = [ - "either", - "env_home", - "rustix", - "winsafe", + "libc", ] [[package]] @@ -4233,7 +4476,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -4244,7 +4487,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -4316,6 +4559,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -4361,6 +4613,21 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -4427,6 +4694,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -4445,6 +4718,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -4463,6 +4742,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -4493,6 +4778,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -4511,6 +4802,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -4529,6 +4826,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -4547,6 +4850,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -4567,24 +4876,109 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] [[package]] -name = "winsafe" -version = "0.0.19" +name = "winnow" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -4623,15 +5017,15 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", "synstructure", ] [[package]] name = "zbus" -version = "5.12.0" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" dependencies = [ "async-broadcast", "async-executor", @@ -4647,15 +5041,16 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix", + "libc", "ordered-stream", + "rustix", "serde", "serde_repr", "tracing", "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 0.7.15", "zbus_macros", "zbus_names", "zvariant", @@ -4663,14 +5058,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.12.0" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", @@ -4678,34 +5073,33 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.2.0" +version = "4.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "static_assertions", - "winnow", + "winnow 0.7.15", "zvariant", ] [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -4725,7 +5119,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", "synstructure", ] @@ -4765,51 +5159,51 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] name = "zmij" -version = "1.0.0" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d6085d62852e35540689d1f97ad663e3971fc19cf5eceab364d62c646ea167" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zvariant" -version = "5.8.0" +version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 0.7.15", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.8.0" +version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.111", - "winnow", + "syn 2.0.117", + "winnow 0.7.15", ] diff --git a/crates/owlry-lua/Cargo.toml b/crates/owlry-lua/Cargo.toml index 60c6d29..f40be96 100644 --- a/crates/owlry-lua/Cargo.toml +++ b/crates/owlry-lua/Cargo.toml @@ -20,7 +20,7 @@ owlry-plugin-api = { path = "../owlry-plugin-api" } abi_stable = "0.11" # Lua runtime -mlua = { version = "0.10", features = ["lua54", "vendored", "send", "serialize"] } +mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"] } # Plugin manifest parsing toml = "0.8" @@ -31,7 +31,7 @@ serde_json = "1.0" semver = "1" # HTTP client for plugins -reqwest = { version = "0.12", features = ["blocking", "json"] } +reqwest = { version = "0.13", features = ["blocking", "json"] } # Math expression evaluation meval = "0.2" diff --git a/crates/owlry-plugin-bookmarks/Cargo.toml b/crates/owlry-plugin-bookmarks/Cargo.toml index 57cd7f2..65f961a 100644 --- a/crates/owlry-plugin-bookmarks/Cargo.toml +++ b/crates/owlry-plugin-bookmarks/Cargo.toml @@ -28,4 +28,4 @@ serde_json = "1.0" # For reading Firefox bookmarks (places.sqlite) # Use bundled SQLite to avoid system library version conflicts -rusqlite = { version = "0.32", features = ["bundled"] } +rusqlite = { version = "0.39", features = ["bundled"] } diff --git a/crates/owlry-plugin-weather/Cargo.toml b/crates/owlry-plugin-weather/Cargo.toml index 56037f1..894478d 100644 --- a/crates/owlry-plugin-weather/Cargo.toml +++ b/crates/owlry-plugin-weather/Cargo.toml @@ -20,7 +20,7 @@ owlry-plugin-api = { path = "../owlry-plugin-api" } abi_stable = "0.11" # HTTP client for weather API requests -reqwest = { version = "0.12", features = ["blocking", "json"] } +reqwest = { version = "0.13", features = ["blocking", "json"] } # JSON parsing for API responses serde = { version = "1.0", features = ["derive"] } diff --git a/crates/owlry-rune/Cargo.toml b/crates/owlry-rune/Cargo.toml index 7334676..713183c 100644 --- a/crates/owlry-rune/Cargo.toml +++ b/crates/owlry-rune/Cargo.toml @@ -22,7 +22,7 @@ log = "0.4" env_logger = "0.11" # HTTP client for network API -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"] } # Serialization serde = { version = "1", features = ["derive"] } diff --git a/crates/owlry/Cargo.toml b/crates/owlry/Cargo.toml index 7d5ec4e..7774f5a 100644 --- a/crates/owlry/Cargo.toml +++ b/crates/owlry/Cargo.toml @@ -24,7 +24,7 @@ gtk4-layer-shell = "0.7" fuzzy-matcher = "0.3" # XDG desktop entry parsing -freedesktop-desktop-entry = "0.7" +freedesktop-desktop-entry = "0.8" # Directory utilities dirs = "5" @@ -56,10 +56,10 @@ serde_json = "1" chrono = { version = "0.4", features = ["serde"] } # HTTP client (for Lua plugins) -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"], optional = true } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"], optional = true } # Lua runtime for plugin system (optional - can be loaded dynamically via owlry-lua) -mlua = { version = "0.10", features = ["lua54", "vendored", "send", "serialize"], optional = true } +mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true } # Semantic versioning for plugin compatibility semver = "1" From 8494a806bf3f5c1c001c5be6333684f7d6bf6c66 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 11:53:00 +0100 Subject: [PATCH 03/20] feat(owlry-core): scaffold new core crate --- Cargo.toml | 1 + crates/owlry-core/Cargo.toml | 45 ++++++++++++++++++++++++++++++++++++ crates/owlry-core/src/lib.rs | 7 ++++++ 3 files changed, 53 insertions(+) create mode 100644 crates/owlry-core/Cargo.toml create mode 100644 crates/owlry-core/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index cb0e36c..13cb96e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "crates/owlry-plugin-systemd", "crates/owlry-lua", "crates/owlry-rune", + "crates/owlry-core", ] # Shared workspace settings diff --git a/crates/owlry-core/Cargo.toml b/crates/owlry-core/Cargo.toml new file mode 100644 index 0000000..0865c10 --- /dev/null +++ b/crates/owlry-core/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "owlry-core" +version = "0.5.0" +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" + +[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" +toml = "0.8" +chrono = { version = "0.4", features = ["serde"] } +dirs = "5" + +# Logging & notifications +log = "0.4" +env_logger = "0.11" +notify-rust = "4" + +# Optional: embedded Lua runtime +mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true } +meval = { version = "0.2", optional = true } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"], optional = true } + +[features] +default = [] +lua = ["dep:mlua", "dep:meval", "dep:reqwest"] +dev-logging = [] diff --git a/crates/owlry-core/src/lib.rs b/crates/owlry-core/src/lib.rs new file mode 100644 index 0000000..7396113 --- /dev/null +++ b/crates/owlry-core/src/lib.rs @@ -0,0 +1,7 @@ +pub mod config; +pub mod data; +pub mod filter; +pub mod notify; +pub mod paths; +pub mod plugins; +pub mod providers; From d79c9087fd63dfbf50ab8bd8c1c66aa45f19a6c3 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 12:06:34 +0100 Subject: [PATCH 04/20] feat(owlry-core): move backend modules from owlry Move the following modules from crates/owlry/src/ to crates/owlry-core/src/: - config/ (configuration loading and types) - data/ (frecency store) - filter.rs (provider filtering and prefix parsing) - notify.rs (desktop notifications) - paths.rs (XDG path handling) - plugins/ (plugin system: native loader, manifest, registry, runtime loader, Lua API) - providers/ (provider trait, manager, application, command, native_provider, lua_provider) Notable changes from the original: - providers/mod.rs: ProviderManager constructor changed from with_native_plugins() to new(core_providers, native_providers) to decouple from DmenuProvider (which stays in owlry as a UI concern) - plugins/mod.rs: commands module removed (stays in owlry as CLI concern) - Added thiserror and tempfile dependencies to owlry-core Cargo.toml --- crates/owlry-core/src/config/mod.rs | 574 +++++++++++++++++ crates/owlry-core/src/data/frecency.rs | 219 +++++++ crates/owlry-core/src/data/mod.rs | 3 + crates/owlry-core/src/filter.rs | 409 ++++++++++++ crates/owlry-core/src/notify.rs | 91 +++ crates/owlry-core/src/paths.rs | 203 ++++++ crates/owlry-core/src/plugins/api/action.rs | 322 ++++++++++ crates/owlry-core/src/plugins/api/cache.rs | 299 +++++++++ crates/owlry-core/src/plugins/api/hook.rs | 410 ++++++++++++ crates/owlry-core/src/plugins/api/http.rs | 345 ++++++++++ crates/owlry-core/src/plugins/api/math.rs | 181 ++++++ crates/owlry-core/src/plugins/api/mod.rs | 77 +++ crates/owlry-core/src/plugins/api/process.rs | 207 ++++++ crates/owlry-core/src/plugins/api/provider.rs | 315 +++++++++ crates/owlry-core/src/plugins/api/theme.rs | 275 ++++++++ crates/owlry-core/src/plugins/api/utils.rs | 567 +++++++++++++++++ crates/owlry-core/src/plugins/error.rs | 51 ++ crates/owlry-core/src/plugins/loader.rs | 205 ++++++ crates/owlry-core/src/plugins/manifest.rs | 318 ++++++++++ crates/owlry-core/src/plugins/mod.rs | 336 ++++++++++ .../owlry-core/src/plugins/native_loader.rs | 391 ++++++++++++ crates/owlry-core/src/plugins/registry.rs | 293 +++++++++ crates/owlry-core/src/plugins/runtime.rs | 153 +++++ .../owlry-core/src/plugins/runtime_loader.rs | 286 +++++++++ .../owlry-core/src/providers/application.rs | 266 ++++++++ crates/owlry-core/src/providers/command.rs | 106 ++++ .../owlry-core/src/providers/lua_provider.rs | 142 +++++ crates/owlry-core/src/providers/mod.rs | 598 ++++++++++++++++++ .../src/providers/native_provider.rs | 197 ++++++ 29 files changed, 7839 insertions(+) create mode 100644 crates/owlry-core/src/config/mod.rs create mode 100644 crates/owlry-core/src/data/frecency.rs create mode 100644 crates/owlry-core/src/data/mod.rs create mode 100644 crates/owlry-core/src/filter.rs create mode 100644 crates/owlry-core/src/notify.rs create mode 100644 crates/owlry-core/src/paths.rs create mode 100644 crates/owlry-core/src/plugins/api/action.rs create mode 100644 crates/owlry-core/src/plugins/api/cache.rs create mode 100644 crates/owlry-core/src/plugins/api/hook.rs create mode 100644 crates/owlry-core/src/plugins/api/http.rs create mode 100644 crates/owlry-core/src/plugins/api/math.rs create mode 100644 crates/owlry-core/src/plugins/api/mod.rs create mode 100644 crates/owlry-core/src/plugins/api/process.rs create mode 100644 crates/owlry-core/src/plugins/api/provider.rs create mode 100644 crates/owlry-core/src/plugins/api/theme.rs create mode 100644 crates/owlry-core/src/plugins/api/utils.rs create mode 100644 crates/owlry-core/src/plugins/error.rs create mode 100644 crates/owlry-core/src/plugins/loader.rs create mode 100644 crates/owlry-core/src/plugins/manifest.rs create mode 100644 crates/owlry-core/src/plugins/mod.rs create mode 100644 crates/owlry-core/src/plugins/native_loader.rs create mode 100644 crates/owlry-core/src/plugins/registry.rs create mode 100644 crates/owlry-core/src/plugins/runtime.rs create mode 100644 crates/owlry-core/src/plugins/runtime_loader.rs create mode 100644 crates/owlry-core/src/providers/application.rs create mode 100644 crates/owlry-core/src/providers/command.rs create mode 100644 crates/owlry-core/src/providers/lua_provider.rs create mode 100644 crates/owlry-core/src/providers/mod.rs create mode 100644 crates/owlry-core/src/providers/native_provider.rs diff --git a/crates/owlry-core/src/config/mod.rs b/crates/owlry-core/src/config/mod.rs new file mode 100644 index 0000000..dc6a57f --- /dev/null +++ b/crates/owlry-core/src/config/mod.rs @@ -0,0 +1,574 @@ +use log::{debug, info, warn}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Command; + +use crate::paths; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Config { + #[serde(default)] + pub general: GeneralConfig, + #[serde(default)] + pub appearance: AppearanceConfig, + #[serde(default)] + pub providers: ProvidersConfig, + #[serde(default)] + pub plugins: PluginsConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeneralConfig { + #[serde(default = "default_true")] + pub show_icons: bool, + #[serde(default = "default_max_results")] + pub max_results: usize, + /// Terminal command (auto-detected if not specified) + #[serde(default)] + pub terminal_command: Option, + /// Enable uwsm (Universal Wayland Session Manager) for launching apps. + /// When enabled, desktop files are launched via `uwsm app -- ` + /// which starts apps in a proper systemd user session. + /// When disabled (default), apps are launched via `gio launch`. + #[serde(default)] + pub use_uwsm: bool, + /// Provider tabs shown in the header bar. + /// Valid values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web + #[serde(default = "default_tabs")] + pub tabs: Vec, +} + +impl Default for GeneralConfig { + fn default() -> Self { + Self { + show_icons: true, + max_results: 100, + terminal_command: None, + use_uwsm: false, + tabs: default_tabs(), + } + } +} + +fn default_max_results() -> usize { + 100 +} + +fn default_tabs() -> Vec { + vec![ + "app".to_string(), + "cmd".to_string(), + "uuctl".to_string(), + ] +} + +/// User-customizable theme colors +/// All fields are optional - unset values inherit from theme or GTK defaults +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ThemeColors { + // Core colors + pub background: Option, + pub background_secondary: Option, + pub border: Option, + pub text: Option, + pub text_secondary: Option, + pub accent: Option, + pub accent_bright: Option, + // Provider badge colors + pub badge_app: Option, + pub badge_bookmark: Option, + pub badge_calc: Option, + pub badge_clip: Option, + pub badge_cmd: Option, + pub badge_dmenu: Option, + pub badge_emoji: Option, + pub badge_file: Option, + pub badge_script: Option, + pub badge_ssh: Option, + pub badge_sys: Option, + pub badge_uuctl: Option, + pub badge_web: Option, + // Widget badge colors + pub badge_media: Option, + pub badge_weather: Option, + pub badge_pomo: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppearanceConfig { + #[serde(default = "default_width")] + pub width: i32, + #[serde(default = "default_height")] + pub height: i32, + #[serde(default = "default_font_size")] + pub font_size: u32, + #[serde(default = "default_border_radius")] + pub border_radius: u32, + /// Theme name: None = GTK default, "owl" = built-in owl theme + #[serde(default)] + pub theme: Option, + /// Individual color overrides + #[serde(default)] + pub colors: ThemeColors, +} + +impl Default for AppearanceConfig { + fn default() -> Self { + Self { + width: 850, + height: 650, + font_size: 14, + border_radius: 12, + theme: None, + colors: ThemeColors::default(), + } + } +} + +fn default_width() -> i32 { 850 } +fn default_height() -> i32 { 650 } +fn default_font_size() -> u32 { 14 } +fn default_border_radius() -> u32 { 12 } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProvidersConfig { + #[serde(default = "default_true")] + pub applications: bool, + #[serde(default = "default_true")] + pub commands: bool, + #[serde(default = "default_true")] + pub uuctl: bool, + /// Enable calculator provider (= expression or calc expression) + #[serde(default = "default_true")] + pub calculator: bool, + /// Enable frecency-based result ranking + #[serde(default = "default_true")] + pub frecency: bool, + /// Weight for frecency boost (0.0 = disabled, 1.0 = strong boost) + #[serde(default = "default_frecency_weight")] + pub frecency_weight: f64, + /// Enable web search provider (? query or web query) + #[serde(default = "default_true")] + pub websearch: bool, + /// Search engine for web search + /// Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia + /// Or custom URL with {query} placeholder + #[serde(default = "default_search_engine")] + pub search_engine: String, + /// Enable system commands (shutdown, reboot, etc.) + #[serde(default = "default_true")] + pub system: bool, + /// Enable SSH connections from ~/.ssh/config + #[serde(default = "default_true")] + pub ssh: bool, + /// Enable clipboard history (requires cliphist) + #[serde(default = "default_true")] + pub clipboard: bool, + /// Enable browser bookmarks + #[serde(default = "default_true")] + pub bookmarks: bool, + /// Enable emoji picker + #[serde(default = "default_true")] + pub emoji: bool, + /// Enable custom scripts from ~/.config/owlry/scripts/ + #[serde(default = "default_true")] + pub scripts: bool, + /// Enable file search (requires fd or locate) + #[serde(default = "default_true")] + pub files: bool, + + // ─── Widget Providers ─────────────────────────────────────────────── + + /// Enable MPRIS media player widget + #[serde(default = "default_true")] + pub media: bool, + + /// Enable weather widget + #[serde(default)] + pub weather: bool, + + /// Weather provider: wttr.in (default), openweathermap, open-meteo + #[serde(default = "default_weather_provider")] + pub weather_provider: String, + + /// API key for weather services that require it (e.g., OpenWeatherMap) + #[serde(default)] + pub weather_api_key: Option, + + /// Location for weather (city name or coordinates) + #[serde(default)] + pub weather_location: Option, + + /// Enable pomodoro timer widget + #[serde(default)] + pub pomodoro: bool, + + /// Pomodoro work duration in minutes + #[serde(default = "default_pomodoro_work")] + pub pomodoro_work_mins: u32, + + /// Pomodoro break duration in minutes + #[serde(default = "default_pomodoro_break")] + pub pomodoro_break_mins: u32, +} + +impl Default for ProvidersConfig { + fn default() -> Self { + Self { + applications: true, + commands: true, + uuctl: true, + calculator: true, + frecency: true, + frecency_weight: 0.3, + websearch: true, + search_engine: "duckduckgo".to_string(), + system: true, + ssh: true, + clipboard: true, + bookmarks: true, + emoji: true, + scripts: true, + files: true, + media: true, + weather: false, + weather_provider: "wttr.in".to_string(), + weather_api_key: None, + weather_location: Some("Berlin".to_string()), + pomodoro: false, + pomodoro_work_mins: 25, + pomodoro_break_mins: 5, + } + } +} + +/// Configuration for plugins +/// +/// Supports per-plugin configuration via `[plugins.]` sections: +/// ```toml +/// [plugins] +/// enabled = true +/// +/// [plugins.weather] +/// location = "Berlin" +/// units = "metric" +/// +/// [plugins.pomodoro] +/// work_mins = 25 +/// break_mins = 5 +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginsConfig { + /// Whether plugins are enabled globally + #[serde(default = "default_true")] + pub enabled: bool, + + /// List of plugin IDs to enable (empty = all discovered plugins) + #[serde(default)] + pub enabled_plugins: Vec, + + /// List of plugin IDs to explicitly disable + #[serde(default)] + pub disabled_plugins: Vec, + + /// Sandbox settings for plugin execution + #[serde(default)] + pub sandbox: SandboxConfig, + + /// Plugin registry URL (for `owlry plugin search` and registry installs) + /// Defaults to the official owlry plugin registry if not specified. + #[serde(default)] + pub registry_url: Option, + + /// Per-plugin configuration tables + /// Accessed via `[plugins.]` sections in config.toml + /// Each plugin can define its own config schema + #[serde(flatten)] + pub plugin_configs: HashMap, +} + +/// Sandbox settings for plugin security +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxConfig { + /// Allow plugins to access the filesystem (beyond their own directory) + #[serde(default)] + pub allow_filesystem: bool, + + /// Allow plugins to make network requests + #[serde(default)] + pub allow_network: bool, + + /// Allow plugins to run shell commands + #[serde(default)] + pub allow_commands: bool, + + /// Memory limit for Lua runtime in bytes (0 = unlimited) + #[serde(default = "default_memory_limit")] + pub memory_limit: usize, +} + +impl Default for PluginsConfig { + fn default() -> Self { + Self { + enabled: true, + enabled_plugins: Vec::new(), + disabled_plugins: Vec::new(), + sandbox: SandboxConfig::default(), + registry_url: None, + plugin_configs: HashMap::new(), + } + } +} + +impl PluginsConfig { + /// Get configuration for a specific plugin by name + /// + /// Returns the plugin's config table if it exists in `[plugins.]` + #[allow(dead_code)] + pub fn get_plugin_config(&self, plugin_name: &str) -> Option<&toml::Value> { + self.plugin_configs.get(plugin_name) + } + + /// Get a string value from a plugin's config + #[allow(dead_code)] + pub fn get_plugin_string(&self, plugin_name: &str, key: &str) -> Option<&str> { + self.plugin_configs + .get(plugin_name)? + .get(key)? + .as_str() + } + + /// Get an integer value from a plugin's config + #[allow(dead_code)] + pub fn get_plugin_int(&self, plugin_name: &str, key: &str) -> Option { + self.plugin_configs + .get(plugin_name)? + .get(key)? + .as_integer() + } + + /// Get a boolean value from a plugin's config + #[allow(dead_code)] + pub fn get_plugin_bool(&self, plugin_name: &str, key: &str) -> Option { + self.plugin_configs + .get(plugin_name)? + .get(key)? + .as_bool() + } +} + +impl Default for SandboxConfig { + fn default() -> Self { + Self { + allow_filesystem: false, + allow_network: false, + allow_commands: false, + memory_limit: default_memory_limit(), + } + } +} + +fn default_memory_limit() -> usize { + 64 * 1024 * 1024 // 64 MB +} + +fn default_search_engine() -> String { + "duckduckgo".to_string() +} + +fn default_true() -> bool { + true +} + +fn default_frecency_weight() -> f64 { + 0.3 +} + +fn default_weather_provider() -> String { + "wttr.in".to_string() +} + +fn default_pomodoro_work() -> u32 { + 25 +} + +fn default_pomodoro_break() -> u32 { + 5 +} + + +/// Detect the best available terminal emulator +/// Fallback chain: +/// 1. $TERMINAL env var (user's explicit preference) +/// 2. xdg-terminal-exec (freedesktop standard - if available) +/// 3. Desktop-environment native terminal (GNOME→gnome-terminal, KDE→konsole, etc.) +/// 4. Common Wayland-native terminals (kitty, alacritty, wezterm, foot) +/// 5. Common X11/legacy terminals +/// 6. x-terminal-emulator (Debian alternatives) +/// 7. xterm (ultimate fallback - the cockroach of terminals) +fn detect_terminal() -> String { + // 1. Check $TERMINAL env var first (user's explicit preference) + if let Ok(term) = std::env::var("TERMINAL") + && !term.is_empty() && command_exists(&term) { + debug!("Using $TERMINAL: {}", term); + return term; + } + + // 2. Try xdg-terminal-exec (freedesktop standard) + if command_exists("xdg-terminal-exec") { + debug!("Using xdg-terminal-exec"); + return "xdg-terminal-exec".to_string(); + } + + // 3. Desktop-environment aware detection + if let Some(term) = detect_de_terminal() { + debug!("Using DE-native terminal: {}", term); + return term; + } + + // 4. Common Wayland-native terminals (preferred for modern setups) + let wayland_terminals = ["kitty", "alacritty", "wezterm", "foot"]; + for term in wayland_terminals { + if command_exists(term) { + debug!("Found Wayland terminal: {}", term); + return term.to_string(); + } + } + + // 5. Common X11/legacy terminals + let legacy_terminals = ["gnome-terminal", "konsole", "xfce4-terminal", "mate-terminal", "tilix", "terminator"]; + for term in legacy_terminals { + if command_exists(term) { + debug!("Found legacy terminal: {}", term); + return term.to_string(); + } + } + + // 6. Try x-terminal-emulator (Debian alternatives system) + if command_exists("x-terminal-emulator") { + debug!("Using x-terminal-emulator"); + return "x-terminal-emulator".to_string(); + } + + // 7. Ultimate fallback - xterm exists everywhere + debug!("Falling back to xterm"); + "xterm".to_string() +} + +/// Detect desktop environment and return its native terminal +fn detect_de_terminal() -> Option { + // Check XDG_CURRENT_DESKTOP first + let desktop = std::env::var("XDG_CURRENT_DESKTOP") + .ok() + .map(|s| s.to_lowercase()); + + // Also check for Wayland compositor-specific env vars + let is_hyprland = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok(); + let is_sway = std::env::var("SWAYSOCK").is_ok(); + + // Map desktop environments to their native/preferred terminals + let candidates: &[&str] = if is_hyprland { + // Hyprland: foot and kitty are most popular in the community + &["foot", "kitty", "alacritty", "wezterm"] + } else if is_sway { + // Sway: foot is the recommended terminal (lightweight, Wayland-native) + &["foot", "alacritty", "kitty", "wezterm"] + } else if let Some(ref de) = desktop { + match de.as_str() { + s if s.contains("gnome") => &["gnome-terminal", "gnome-console", "kgx"], + s if s.contains("kde") || s.contains("plasma") => &["konsole"], + s if s.contains("xfce") => &["xfce4-terminal"], + s if s.contains("mate") => &["mate-terminal"], + s if s.contains("lxqt") => &["qterminal"], + s if s.contains("lxde") => &["lxterminal"], + s if s.contains("cinnamon") => &["gnome-terminal"], + s if s.contains("budgie") => &["tilix", "gnome-terminal"], + s if s.contains("pantheon") => &["io.elementary.terminal", "pantheon-terminal"], + s if s.contains("deepin") => &["deepin-terminal"], + s if s.contains("hyprland") => &["foot", "kitty", "alacritty", "wezterm"], + s if s.contains("sway") => &["foot", "alacritty", "kitty", "wezterm"], + _ => return None, + } + } else { + return None; + }; + + for term in candidates { + if command_exists(term) { + return Some(term.to_string()); + } + } + + None +} + +/// Check if a command exists in PATH +fn command_exists(cmd: &str) -> bool { + Command::new("which") + .arg(cmd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +// Note: Config derives Default via #[derive(Default)] - all sub-structs have impl Default + +impl Config { + pub fn config_path() -> Option { + paths::config_file() + } + + pub fn load_or_default() -> Self { + Self::load().unwrap_or_else(|e| { + warn!("Failed to load config: {}, using defaults", e); + Self::default() + }) + } + + pub fn load() -> Result> { + let path = Self::config_path().ok_or("Could not determine config path")?; + + let mut config = if !path.exists() { + info!("Config file not found, using defaults"); + Self::default() + } else { + let content = std::fs::read_to_string(&path)?; + let config: Config = toml::from_str(&content)?; + info!("Loaded config from {:?}", path); + config + }; + + // Auto-detect terminal if not configured or configured terminal doesn't exist + match &config.general.terminal_command { + None => { + let terminal = detect_terminal(); + info!("Detected terminal: {}", terminal); + config.general.terminal_command = Some(terminal); + } + Some(term) if !command_exists(term) => { + warn!("Configured terminal '{}' not found, auto-detecting", term); + let terminal = detect_terminal(); + info!("Using detected terminal: {}", terminal); + config.general.terminal_command = Some(terminal); + } + Some(term) => { + debug!("Using configured terminal: {}", term); + } + } + + Ok(config) + } + + #[allow(dead_code)] + pub fn save(&self) -> Result<(), Box> { + let path = Self::config_path().ok_or("Could not determine config path")?; + + paths::ensure_parent_dir(&path)?; + + let content = toml::to_string_pretty(self)?; + std::fs::write(&path, content)?; + info!("Saved config to {:?}", path); + Ok(()) + } +} diff --git a/crates/owlry-core/src/data/frecency.rs b/crates/owlry-core/src/data/frecency.rs new file mode 100644 index 0000000..af43413 --- /dev/null +++ b/crates/owlry-core/src/data/frecency.rs @@ -0,0 +1,219 @@ +use chrono::{DateTime, Utc}; +use log::{debug, info, warn}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::paths; + +/// A single frecency entry tracking launch count and recency +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FrecencyEntry { + pub launch_count: u32, + pub last_launch: DateTime, +} + +/// Persistent frecency data store +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FrecencyData { + pub version: u32, + pub entries: HashMap, +} + +impl Default for FrecencyData { + fn default() -> Self { + Self { + version: 1, + entries: HashMap::new(), + } + } +} + +/// Frecency store for tracking and boosting recently/frequently used items +pub struct FrecencyStore { + data: FrecencyData, + path: PathBuf, + dirty: bool, +} + +impl FrecencyStore { + /// Create a new frecency store, loading existing data if available + pub fn new() -> Self { + let path = Self::data_path(); + let data = Self::load_from_path(&path).unwrap_or_default(); + + info!("Frecency store loaded with {} entries", data.entries.len()); + + Self { + data, + path, + dirty: false, + } + } + + /// Alias for new() - loads from disk or creates default + pub fn load_or_default() -> Self { + Self::new() + } + + /// Get the path to the frecency data file + fn data_path() -> PathBuf { + paths::frecency_file().unwrap_or_else(|| PathBuf::from("frecency.json")) + } + + /// Load frecency data from a file + fn load_from_path(path: &PathBuf) -> Option { + if !path.exists() { + debug!("Frecency file not found at {:?}", path); + return None; + } + + let content = std::fs::read_to_string(path).ok()?; + match serde_json::from_str(&content) { + Ok(data) => Some(data), + Err(e) => { + warn!("Failed to parse frecency data: {}", e); + None + } + } + } + + /// Save frecency data to disk + pub fn save(&mut self) -> Result<(), Box> { + if !self.dirty { + return Ok(()); + } + + paths::ensure_parent_dir(&self.path)?; + + let content = serde_json::to_string_pretty(&self.data)?; + std::fs::write(&self.path, content)?; + self.dirty = false; + + debug!("Frecency data saved to {:?}", self.path); + Ok(()) + } + + /// Record a launch event for an item + pub fn record_launch(&mut self, item_id: &str) { + let now = Utc::now(); + + let entry = self + .data + .entries + .entry(item_id.to_string()) + .or_insert(FrecencyEntry { + launch_count: 0, + last_launch: now, + }); + + entry.launch_count += 1; + entry.last_launch = now; + self.dirty = true; + + debug!( + "Recorded launch for '{}': count={}, last={}", + item_id, entry.launch_count, entry.last_launch + ); + + // Auto-save after recording + if let Err(e) = self.save() { + warn!("Failed to save frecency data: {}", e); + } + } + + /// Calculate frecency score for an item + /// Uses Firefox-style algorithm: score = launch_count * recency_weight + pub fn get_score(&self, item_id: &str) -> f64 { + match self.data.entries.get(item_id) { + Some(entry) => Self::calculate_frecency(entry.launch_count, entry.last_launch), + None => 0.0, + } + } + + /// Calculate frecency using Firefox-style algorithm + fn calculate_frecency(launch_count: u32, last_launch: DateTime) -> f64 { + let now = Utc::now(); + let age = now.signed_duration_since(last_launch); + let age_days = age.num_hours() as f64 / 24.0; + + // Recency weight based on how recently the item was used + let recency_weight = if age_days < 1.0 { + 100.0 // Today + } else if age_days < 7.0 { + 70.0 // This week + } else if age_days < 30.0 { + 50.0 // This month + } else if age_days < 90.0 { + 30.0 // This quarter + } else { + 10.0 // Older + }; + + launch_count as f64 * recency_weight + } + + /// Get all entries (for debugging/display) + #[allow(dead_code)] + pub fn entries(&self) -> &HashMap { + &self.data.entries + } + + /// Clear all frecency data + #[allow(dead_code)] + pub fn clear(&mut self) { + self.data.entries.clear(); + self.dirty = true; + } +} + +impl Default for FrecencyStore { + fn default() -> Self { + Self::new() + } +} + +impl Drop for FrecencyStore { + fn drop(&mut self) { + // Attempt to save on drop + if let Err(e) = self.save() { + warn!("Failed to save frecency data on drop: {}", e); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_frecency_calculation() { + let now = Utc::now(); + + // Recent launch should have high score + let score_today = FrecencyStore::calculate_frecency(10, now); + assert!(score_today > 900.0); // 10 * 100 + + // Older launch should have lower score + let week_ago = now - chrono::Duration::days(5); + let score_week = FrecencyStore::calculate_frecency(10, week_ago); + assert!(score_week < score_today); + assert!(score_week > 600.0); // 10 * 70 + + // Much older launch + let month_ago = now - chrono::Duration::days(45); + let score_month = FrecencyStore::calculate_frecency(10, month_ago); + assert!(score_month < score_week); + } + + #[test] + fn test_launch_count_matters() { + let now = Utc::now(); + + let score_few = FrecencyStore::calculate_frecency(2, now); + let score_many = FrecencyStore::calculate_frecency(20, now); + + assert!(score_many > score_few); + assert!((score_many / score_few - 10.0).abs() < 0.1); // Should be ~10x + } +} diff --git a/crates/owlry-core/src/data/mod.rs b/crates/owlry-core/src/data/mod.rs new file mode 100644 index 0000000..8fc1d1b --- /dev/null +++ b/crates/owlry-core/src/data/mod.rs @@ -0,0 +1,3 @@ +mod frecency; + +pub use frecency::FrecencyStore; diff --git a/crates/owlry-core/src/filter.rs b/crates/owlry-core/src/filter.rs new file mode 100644 index 0000000..b9e231e --- /dev/null +++ b/crates/owlry-core/src/filter.rs @@ -0,0 +1,409 @@ +use std::collections::HashSet; + +#[cfg(feature = "dev-logging")] +use log::debug; + +use crate::config::ProvidersConfig; +use crate::providers::ProviderType; + +/// Tracks which providers are enabled and handles prefix-based filtering +#[derive(Debug, Clone)] +pub struct ProviderFilter { + enabled: HashSet, + active_prefix: Option, +} + +/// Result of parsing a query for prefix syntax +#[derive(Debug, Clone)] +pub struct ParsedQuery { + pub prefix: Option, + pub tag_filter: Option, + pub query: String, +} + +impl ProviderFilter { + /// Create filter from CLI args and config + pub fn new( + cli_mode: Option, + cli_providers: Option>, + config_providers: &ProvidersConfig, + ) -> Self { + let enabled = if let Some(mode) = cli_mode { + // --mode overrides everything: single provider + HashSet::from([mode]) + } else if let Some(providers) = cli_providers { + // --providers overrides config + providers.into_iter().collect() + } else { + // Use config file settings, default to apps only + let mut set = HashSet::new(); + // Core providers + if config_providers.applications { + set.insert(ProviderType::Application); + } + if config_providers.commands { + set.insert(ProviderType::Command); + } + // Plugin providers - use Plugin(type_id) for all + if config_providers.uuctl { + set.insert(ProviderType::Plugin("uuctl".to_string())); + } + if config_providers.system { + set.insert(ProviderType::Plugin("system".to_string())); + } + if config_providers.ssh { + set.insert(ProviderType::Plugin("ssh".to_string())); + } + if config_providers.clipboard { + set.insert(ProviderType::Plugin("clipboard".to_string())); + } + if config_providers.bookmarks { + set.insert(ProviderType::Plugin("bookmarks".to_string())); + } + if config_providers.emoji { + set.insert(ProviderType::Plugin("emoji".to_string())); + } + if config_providers.scripts { + set.insert(ProviderType::Plugin("scripts".to_string())); + } + // Dynamic providers + if config_providers.files { + set.insert(ProviderType::Plugin("filesearch".to_string())); + } + if config_providers.calculator { + set.insert(ProviderType::Plugin("calc".to_string())); + } + if config_providers.websearch { + set.insert(ProviderType::Plugin("websearch".to_string())); + } + // Default to apps if nothing enabled + if set.is_empty() { + set.insert(ProviderType::Application); + } + set + }; + + let filter = Self { + enabled, + active_prefix: None, + }; + + #[cfg(feature = "dev-logging")] + debug!("[Filter] Created with enabled providers: {:?}", filter.enabled); + + filter + } + + /// Default filter: apps only + #[allow(dead_code)] + pub fn apps_only() -> Self { + Self { + enabled: HashSet::from([ProviderType::Application]), + active_prefix: None, + } + } + + /// Toggle a provider on/off + pub fn toggle(&mut self, provider: ProviderType) { + if self.enabled.contains(&provider) { + self.enabled.remove(&provider); + // Ensure at least one provider is always enabled + if self.enabled.is_empty() { + self.enabled.insert(ProviderType::Application); + } + #[cfg(feature = "dev-logging")] + debug!("[Filter] Toggled OFF {:?}, enabled: {:?}", provider, self.enabled); + } else { + #[cfg(feature = "dev-logging")] + let provider_debug = format!("{:?}", provider); + self.enabled.insert(provider); + #[cfg(feature = "dev-logging")] + debug!("[Filter] Toggled ON {}, enabled: {:?}", provider_debug, self.enabled); + } + } + + /// Enable a specific provider + pub fn enable(&mut self, provider: ProviderType) { + self.enabled.insert(provider); + } + + /// Disable a specific provider (ensures at least one remains) + pub fn disable(&mut self, provider: ProviderType) { + self.enabled.remove(&provider); + if self.enabled.is_empty() { + self.enabled.insert(ProviderType::Application); + } + } + + /// Set to single provider mode + pub fn set_single_mode(&mut self, provider: ProviderType) { + self.enabled.clear(); + self.enabled.insert(provider); + } + + /// Set prefix mode (from :app, :cmd, etc.) + pub fn set_prefix(&mut self, prefix: Option) { + #[cfg(feature = "dev-logging")] + if self.active_prefix != prefix { + debug!("[Filter] Prefix changed: {:?} -> {:?}", self.active_prefix, prefix); + } + self.active_prefix = prefix; + } + + /// Check if a provider should be searched + pub fn is_active(&self, provider: ProviderType) -> bool { + if let Some(ref prefix) = self.active_prefix { + &provider == prefix + } else { + self.enabled.contains(&provider) + } + } + + /// Check if provider is in enabled set (ignoring prefix) + pub fn is_enabled(&self, provider: ProviderType) -> bool { + self.enabled.contains(&provider) + } + + /// Get current active prefix if any + #[allow(dead_code)] + pub fn active_prefix(&self) -> Option { + self.active_prefix.clone() + } + + /// Parse query for prefix syntax + /// Prefixes map to Plugin(type_id) for plugin providers + pub fn parse_query(query: &str) -> ParsedQuery { + let trimmed = query.trim_start(); + + // Check for tag filter pattern: ":tag:XXX query" or ":tag:XXX" + if let Some(rest) = trimmed.strip_prefix(":tag:") { + // Find the end of the tag (space or end of string) + if let Some(space_idx) = rest.find(' ') { + let tag = rest[..space_idx].to_lowercase(); + let query_part = rest[space_idx + 1..].to_string(); + #[cfg(feature = "dev-logging")] + debug!("[Filter] parse_query({:?}) -> tag={:?}, query={:?}", query, tag, query_part); + return ParsedQuery { + prefix: None, + tag_filter: Some(tag), + query: query_part, + }; + } else { + // Just the tag, no query yet + let tag = rest.to_lowercase(); + return ParsedQuery { + prefix: None, + tag_filter: Some(tag), + query: String::new(), + }; + } + } + + // Core provider prefixes + let core_prefixes: &[(&str, ProviderType)] = &[ + (":app ", ProviderType::Application), + (":apps ", ProviderType::Application), + (":cmd ", ProviderType::Command), + (":command ", ProviderType::Command), + ]; + + // Plugin provider prefixes - mapped to Plugin(type_id) + let plugin_prefixes: &[(&str, &str)] = &[ + (":bm ", "bookmarks"), + (":bookmark ", "bookmarks"), + (":bookmarks ", "bookmarks"), + (":calc ", "calc"), + (":calculator ", "calc"), + (":clip ", "clipboard"), + (":clipboard ", "clipboard"), + (":emoji ", "emoji"), + (":emojis ", "emoji"), + (":file ", "filesearch"), + (":files ", "filesearch"), + (":find ", "filesearch"), + (":script ", "scripts"), + (":scripts ", "scripts"), + (":ssh ", "ssh"), + (":sys ", "system"), + (":system ", "system"), + (":power ", "system"), + (":uuctl ", "uuctl"), + (":systemd ", "uuctl"), + (":web ", "websearch"), + (":search ", "websearch"), + ]; + + // Check core prefixes + for (prefix_str, provider) in core_prefixes { + if let Some(rest) = trimmed.strip_prefix(prefix_str) { + #[cfg(feature = "dev-logging")] + debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, provider, rest); + return ParsedQuery { + prefix: Some(provider.clone()), + tag_filter: None, + query: rest.to_string(), + }; + } + } + + // Check plugin prefixes + for (prefix_str, type_id) in plugin_prefixes { + if let Some(rest) = trimmed.strip_prefix(prefix_str) { + let provider = ProviderType::Plugin(type_id.to_string()); + #[cfg(feature = "dev-logging")] + debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, provider, rest); + return ParsedQuery { + prefix: Some(provider), + tag_filter: None, + query: rest.to_string(), + }; + } + } + + // Handle partial prefixes (still typing) + let partial_core: &[(&str, ProviderType)] = &[ + (":app", ProviderType::Application), + (":apps", ProviderType::Application), + (":cmd", ProviderType::Command), + (":command", ProviderType::Command), + ]; + + let partial_plugin: &[(&str, &str)] = &[ + (":bm", "bookmarks"), + (":bookmark", "bookmarks"), + (":bookmarks", "bookmarks"), + (":calc", "calc"), + (":calculator", "calc"), + (":clip", "clipboard"), + (":clipboard", "clipboard"), + (":emoji", "emoji"), + (":emojis", "emoji"), + (":file", "filesearch"), + (":files", "filesearch"), + (":find", "filesearch"), + (":script", "scripts"), + (":scripts", "scripts"), + (":ssh", "ssh"), + (":sys", "system"), + (":system", "system"), + (":power", "system"), + (":uuctl", "uuctl"), + (":systemd", "uuctl"), + (":web", "websearch"), + (":search", "websearch"), + ]; + + for (prefix_str, provider) in partial_core { + if trimmed == *prefix_str { + #[cfg(feature = "dev-logging")] + debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider); + return ParsedQuery { + prefix: Some(provider.clone()), + tag_filter: None, + query: String::new(), + }; + } + } + + for (prefix_str, type_id) in partial_plugin { + if trimmed == *prefix_str { + let provider = ProviderType::Plugin(type_id.to_string()); + #[cfg(feature = "dev-logging")] + debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider); + return ParsedQuery { + prefix: Some(provider), + tag_filter: None, + query: String::new(), + }; + } + } + + let result = ParsedQuery { + prefix: None, + tag_filter: None, + query: query.to_string(), + }; + + #[cfg(feature = "dev-logging")] + debug!("[Filter] parse_query({:?}) -> prefix={:?}, tag={:?}, query={:?}", query, result.prefix, result.tag_filter, result.query); + + result + } + + /// Get enabled providers for UI display (sorted) + pub fn enabled_providers(&self) -> Vec { + let mut providers: Vec<_> = self.enabled.iter().cloned().collect(); + providers.sort_by_key(|p| match p { + ProviderType::Application => 0, + ProviderType::Command => 1, + ProviderType::Dmenu => 2, + ProviderType::Plugin(_) => 100, // Plugin providers sort after core + }); + providers + } + + /// Get display name for current mode + pub fn mode_display_name(&self) -> &'static str { + if let Some(ref prefix) = self.active_prefix { + return match prefix { + ProviderType::Application => "Apps", + ProviderType::Command => "Commands", + ProviderType::Dmenu => "dmenu", + ProviderType::Plugin(_) => "Plugin", + }; + } + + let enabled: Vec<_> = self.enabled_providers(); + if enabled.len() == 1 { + match &enabled[0] { + ProviderType::Application => "Apps", + ProviderType::Command => "Commands", + ProviderType::Dmenu => "dmenu", + ProviderType::Plugin(_) => "Plugin", + } + } else { + "All" + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_query_with_prefix() { + let result = ProviderFilter::parse_query(":app firefox"); + assert_eq!(result.prefix, Some(ProviderType::Application)); + assert_eq!(result.query, "firefox"); + } + + #[test] + fn test_parse_query_without_prefix() { + let result = ProviderFilter::parse_query("firefox"); + assert_eq!(result.prefix, None); + assert_eq!(result.query, "firefox"); + } + + #[test] + fn test_parse_query_partial_prefix() { + let result = ProviderFilter::parse_query(":cmd"); + assert_eq!(result.prefix, Some(ProviderType::Command)); + assert_eq!(result.query, ""); + } + + #[test] + fn test_parse_query_plugin_prefix() { + let result = ProviderFilter::parse_query(":calc 5+3"); + assert_eq!(result.prefix, Some(ProviderType::Plugin("calc".to_string()))); + assert_eq!(result.query, "5+3"); + } + + #[test] + fn test_toggle_ensures_one_enabled() { + let mut filter = ProviderFilter::apps_only(); + filter.toggle(ProviderType::Application); + // Should still have apps enabled as fallback + assert!(filter.is_enabled(ProviderType::Application)); + } +} diff --git a/crates/owlry-core/src/notify.rs b/crates/owlry-core/src/notify.rs new file mode 100644 index 0000000..dbfc9ac --- /dev/null +++ b/crates/owlry-core/src/notify.rs @@ -0,0 +1,91 @@ +//! Desktop notification system +//! +//! Provides system notifications for owlry and its plugins. +//! Uses the freedesktop notification specification via notify-rust. +//! +//! Note: Some convenience functions are provided for future use and +//! are currently unused by the core (plugins use the Host API instead). + +#![allow(dead_code)] + +use notify_rust::{Notification, Urgency}; + +/// Notification urgency level +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum NotifyUrgency { + /// Low priority notification + Low, + /// Normal priority notification (default) + #[default] + Normal, + /// Critical/urgent notification + Critical, +} + +impl From for Urgency { + fn from(urgency: NotifyUrgency) -> Self { + match urgency { + NotifyUrgency::Low => Urgency::Low, + NotifyUrgency::Normal => Urgency::Normal, + NotifyUrgency::Critical => Urgency::Critical, + } + } +} + +/// Send a simple notification +pub fn notify(summary: &str, body: &str) { + notify_with_options(summary, body, None, NotifyUrgency::Normal); +} + +/// Send a notification with an icon +pub fn notify_with_icon(summary: &str, body: &str, icon: &str) { + notify_with_options(summary, body, Some(icon), NotifyUrgency::Normal); +} + +/// Send a notification with full options +pub fn notify_with_options(summary: &str, body: &str, icon: Option<&str>, urgency: NotifyUrgency) { + let mut notification = Notification::new(); + notification + .appname("Owlry") + .summary(summary) + .body(body) + .urgency(urgency.into()); + + if let Some(icon_name) = icon { + notification.icon(icon_name); + } + + if let Err(e) = notification.show() { + log::warn!("Failed to show notification: {}", e); + } +} + +/// Send a notification with a timeout +pub fn notify_with_timeout(summary: &str, body: &str, icon: Option<&str>, timeout_ms: i32) { + let mut notification = Notification::new(); + notification + .appname("Owlry") + .summary(summary) + .body(body) + .timeout(timeout_ms); + + if let Some(icon_name) = icon { + notification.icon(icon_name); + } + + if let Err(e) = notification.show() { + log::warn!("Failed to show notification: {}", e); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_urgency_conversion() { + assert_eq!(Urgency::from(NotifyUrgency::Low), Urgency::Low); + assert_eq!(Urgency::from(NotifyUrgency::Normal), Urgency::Normal); + assert_eq!(Urgency::from(NotifyUrgency::Critical), Urgency::Critical); + } +} diff --git a/crates/owlry-core/src/paths.rs b/crates/owlry-core/src/paths.rs new file mode 100644 index 0000000..a846063 --- /dev/null +++ b/crates/owlry-core/src/paths.rs @@ -0,0 +1,203 @@ +//! Centralized path handling following XDG Base Directory Specification. +//! +//! XDG directories used: +//! - `$XDG_CONFIG_HOME/owlry/` - User configuration (config.toml, themes/, style.css) +//! - `$XDG_DATA_HOME/owlry/` - User data (scripts/, frecency.json) +//! - `$XDG_CACHE_HOME/owlry/` - Cache files (future use) +//! +//! See: https://specifications.freedesktop.org/basedir-spec/latest/ + +use std::path::PathBuf; + +/// Application name used in XDG paths +const APP_NAME: &str = "owlry"; + +// ============================================================================= +// XDG Base Directories +// ============================================================================= + +/// Get XDG config home: `$XDG_CONFIG_HOME` or `~/.config` +pub fn config_home() -> Option { + dirs::config_dir() +} + +/// Get XDG data home: `$XDG_DATA_HOME` or `~/.local/share` +pub fn data_home() -> Option { + dirs::data_dir() +} + +/// Get XDG cache home: `$XDG_CACHE_HOME` or `~/.cache` +#[allow(dead_code)] +pub fn cache_home() -> Option { + dirs::cache_dir() +} + + +// ============================================================================= +// Owlry-specific directories +// ============================================================================= + +/// Owlry config directory: `$XDG_CONFIG_HOME/owlry/` +pub fn owlry_config_dir() -> Option { + config_home().map(|p| p.join(APP_NAME)) +} + +/// Owlry data directory: `$XDG_DATA_HOME/owlry/` +pub fn owlry_data_dir() -> Option { + data_home().map(|p| p.join(APP_NAME)) +} + +/// Owlry cache directory: `$XDG_CACHE_HOME/owlry/` +#[allow(dead_code)] +pub fn owlry_cache_dir() -> Option { + cache_home().map(|p| p.join(APP_NAME)) +} + +// ============================================================================= +// Config files +// ============================================================================= + +/// Main config file: `$XDG_CONFIG_HOME/owlry/config.toml` +pub fn config_file() -> Option { + owlry_config_dir().map(|p| p.join("config.toml")) +} + +/// Custom user stylesheet: `$XDG_CONFIG_HOME/owlry/style.css` +pub fn custom_style_file() -> Option { + owlry_config_dir().map(|p| p.join("style.css")) +} + +/// User themes directory: `$XDG_CONFIG_HOME/owlry/themes/` +pub fn themes_dir() -> Option { + owlry_config_dir().map(|p| p.join("themes")) +} + +/// Get path for a specific theme: `$XDG_CONFIG_HOME/owlry/themes/{name}.css` +pub fn theme_file(name: &str) -> Option { + themes_dir().map(|p| p.join(format!("{}.css", name))) +} + +// ============================================================================= +// Data files +// ============================================================================= + +/// User plugins directory: `$XDG_CONFIG_HOME/owlry/plugins/` +/// +/// Plugins are stored in config because they contain user-installed code +/// that the user explicitly chose to add (similar to themes). +pub fn plugins_dir() -> Option { + owlry_config_dir().map(|p| p.join("plugins")) +} + +/// Frecency data file: `$XDG_DATA_HOME/owlry/frecency.json` +pub fn frecency_file() -> Option { + owlry_data_dir().map(|p| p.join("frecency.json")) +} + +// ============================================================================= +// System directories +// ============================================================================= + +/// System data directories for applications (XDG_DATA_DIRS) +/// +/// Follows the XDG Base Directory Specification: +/// - $XDG_DATA_HOME/applications (defaults to ~/.local/share/applications) +/// - $XDG_DATA_DIRS/*/applications (defaults to /usr/local/share:/usr/share) +/// - Additional Flatpak and Snap directories +pub fn system_data_dirs() -> Vec { + let mut dirs = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + // Helper to add unique directories + let mut add_dir = |path: PathBuf| { + if seen.insert(path.clone()) { + dirs.push(path); + } + }; + + // 1. User data directory first (highest priority) + if let Some(data) = data_home() { + add_dir(data.join("applications")); + } + + // 2. XDG_DATA_DIRS - parse the environment variable + // Default per spec: /usr/local/share:/usr/share + let xdg_data_dirs = std::env::var("XDG_DATA_DIRS") + .unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string()); + + for dir in xdg_data_dirs.split(':') { + if !dir.is_empty() { + add_dir(PathBuf::from(dir).join("applications")); + } + } + + // 3. Always include standard system directories as fallback + // Some environments set XDG_DATA_DIRS without including these + add_dir(PathBuf::from("/usr/share/applications")); + add_dir(PathBuf::from("/usr/local/share/applications")); + + // 4. Flatpak directories (user and system) + if let Some(data) = data_home() { + add_dir(data.join("flatpak/exports/share/applications")); + } + add_dir(PathBuf::from("/var/lib/flatpak/exports/share/applications")); + + // 5. Snap directories + add_dir(PathBuf::from("/var/lib/snapd/desktop/applications")); + + // 6. Nix directories (common on NixOS) + if let Some(home) = dirs::home_dir() { + add_dir(home.join(".nix-profile/share/applications")); + } + add_dir(PathBuf::from("/run/current-system/sw/share/applications")); + + dirs +} + +// ============================================================================= +// Helper functions +// ============================================================================= + +/// Ensure parent directory of a file exists +pub fn ensure_parent_dir(path: &std::path::Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() + && !parent.exists() { + std::fs::create_dir_all(parent)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_paths_are_consistent() { + // All owlry paths should be under XDG directories + if let (Some(config), Some(data)) = (owlry_config_dir(), owlry_data_dir()) { + assert!(config.ends_with("owlry")); + assert!(data.ends_with("owlry")); + } + } + + #[test] + fn test_config_file_path() { + if let Some(path) = config_file() { + assert!(path.ends_with("config.toml")); + assert!(path.to_string_lossy().contains("owlry")); + } + } + + #[test] + fn test_frecency_in_data_dir() { + if let Some(path) = frecency_file() { + assert!(path.ends_with("frecency.json")); + // Should be in data dir, not config dir + let path_str = path.to_string_lossy(); + assert!( + path_str.contains(".local/share") || path_str.contains("XDG_DATA_HOME"), + "frecency should be in data directory" + ); + } + } +} diff --git a/crates/owlry-core/src/plugins/api/action.rs b/crates/owlry-core/src/plugins/api/action.rs new file mode 100644 index 0000000..985f574 --- /dev/null +++ b/crates/owlry-core/src/plugins/api/action.rs @@ -0,0 +1,322 @@ +//! 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 new file mode 100644 index 0000000..448b066 --- /dev/null +++ b/crates/owlry-core/src/plugins/api/cache.rs @@ -0,0 +1,299 @@ +//! 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 new file mode 100644 index 0000000..b660964 --- /dev/null +++ b/crates/owlry-core/src/plugins/api/hook.rs @@ -0,0 +1,410 @@ +//! 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 new file mode 100644 index 0000000..49b7490 --- /dev/null +++ b/crates/owlry-core/src/plugins/api/http.rs @@ -0,0 +1,345 @@ +//! 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 new file mode 100644 index 0000000..54a961c --- /dev/null +++ b/crates/owlry-core/src/plugins/api/math.rs @@ -0,0 +1,181 @@ +//! Math calculation API for Lua plugins +//! +//! Provides safe math expression evaluation: +//! - `owlry.math.calculate(expression)` - Evaluate a math expression + +use mlua::{Lua, Result as LuaResult, 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 meval::eval_str(&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.to_string()))) + } + } + })?, + )?; + + // 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| { + meval::eval_str(&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 new file mode 100644 index 0000000..10fa1ef --- /dev/null +++ b/crates/owlry-core/src/plugins/api/mod.rs @@ -0,0 +1,77 @@ +//! 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 new file mode 100644 index 0000000..b8b5204 --- /dev/null +++ b/crates/owlry-core/src/plugins/api/process.rs @@ -0,0 +1,207 @@ +//! 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 new file mode 100644 index 0000000..124c240 --- /dev/null +++ b/crates/owlry-core/src/plugins/api/provider.rs @@ -0,0 +1,315 @@ +//! 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 new file mode 100644 index 0000000..e500222 --- /dev/null +++ b/crates/owlry-core/src/plugins/api/theme.rs @@ -0,0 +1,275 @@ +//! 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 new file mode 100644 index 0000000..2f6df20 --- /dev/null +++ b/crates/owlry-core/src/plugins/api/utils.rs @@ -0,0 +1,567 @@ +//! 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 new file mode 100644 index 0000000..af6ce43 --- /dev/null +++ b/crates/owlry-core/src/plugins/error.rs @@ -0,0 +1,51 @@ +//! 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 new file mode 100644 index 0000000..4a6f0ee --- /dev/null +++ b/crates/owlry-core/src/plugins/loader.rs @@ -0,0 +1,205 @@ +//! 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::{create_lua_runtime, load_file, SandboxConfig}; + +/// 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::*; + use super::super::manifest::{check_compatibility, discover_plugins}; + 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 new file mode 100644 index 0000000..929d6cf --- /dev/null +++ b/crates/owlry-core/src/plugins/manifest.rs @@ -0,0 +1,318 @@ +//! 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, + #[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")] + pub entry: String, +} + +fn default_owlry_version() -> String { + ">=0.1.0".to_string() +} + +fn default_entry() -> String { + "init.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, + /// 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, "init.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_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 new file mode 100644 index 0000000..cbc64e2 --- /dev/null +++ b/crates/owlry-core/src/plugins/mod.rs @@ -0,0 +1,336 @@ +//! Owlry Plugin System +//! +//! This module provides plugin support for extending owlry's functionality. +//! Plugins can register providers, actions, themes, and hooks. +//! +//! # Plugin Types +//! +//! - **Native plugins** (.so): Pre-compiled Rust plugins loaded from `/usr/lib/owlry/plugins/` +//! - **Lua plugins**: Script-based plugins from `~/.config/owlry/plugins/` (requires `lua` feature) +//! +//! # Plugin Structure (Lua) +//! +//! Each Lua plugin lives in its own directory under `~/.config/owlry/plugins/`: +//! +//! ```text +//! ~/.config/owlry/plugins/ +//! my-plugin/ +//! plugin.toml # Plugin manifest +//! init.lua # Entry point +//! lib/ # Optional modules +//! ``` + +// Always available +pub mod error; +pub mod manifest; +pub mod native_loader; +pub mod registry; +pub mod runtime_loader; + +// 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::{check_compatibility, discover_plugins, PluginManifest}; + +// ============================================================================ +// Lua Plugin Manager (only available with lua feature) +// ============================================================================ + +#[cfg(feature = "lua")] +mod lua_manager { + use super::*; + use std::cell::RefCell; + use std::collections::HashMap; + use std::path::PathBuf; + use std::rc::Rc; + + use manifest::{discover_plugins, check_compatibility}; + + /// 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 (Rc> allows sharing with LuaProviders) + 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, Rc::new(RefCell::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.borrow_mut(); + 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 (returns Rc for shared ownership) + #[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.borrow().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.borrow().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.borrow_mut(); + + 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.borrow_mut().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.borrow().manifest.provides.providers.contains(&provider_name.to_string())) + .map(|p| p.borrow().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.borrow().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.borrow().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.borrow().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 new file mode 100644 index 0000000..05d539d --- /dev/null +++ b/crates/owlry-core/src/plugins/native_loader.rs @@ -0,0 +1,391 @@ +//! 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}; + +use libloading::Library; +use log::{debug, error, info, warn}; +use owlry_plugin_api::{ + HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo, ProviderKind, + RStr, API_VERSION, +}; + +use crate::notify; + +// ============================================================================ +// 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, +}; + +/// 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 new file mode 100644 index 0000000..42c6798 --- /dev/null +++ b/crates/owlry-core/src/plugins/registry.rs @@ -0,0 +1,293 @@ +//! 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 new file mode 100644 index 0000000..da98dbe --- /dev/null +++ b/crates/owlry-core/src/plugins/runtime.rs @@ -0,0 +1,153 @@ +//! 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 new file mode 100644 index 0000000..de62fcd --- /dev/null +++ b/crates/owlry-core/src/plugins/runtime_loader.rs @@ -0,0 +1,286 @@ +//! 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. + +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use libloading::{Library, Symbol}; +use owlry_plugin_api::{PluginItem, RStr, RString, RVec}; + +use super::error::{PluginError, PluginResult}; +use crate::providers::{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, +} + +// Type alias for backwards compatibility +pub type LuaProviderInfo = ScriptProviderInfo; + +/// Handle to runtime-managed state +#[repr(transparent)] +#[derive(Clone, Copy)] +pub struct RuntimeHandle(pub *mut ()); + +/// 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<'_>) -> 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 + _library: Arc, + /// Runtime vtable + vtable: &'static ScriptRuntimeVTable, + /// Runtime handle (state) + handle: RuntimeHandle, + /// Provider information + providers: Vec, +} + +impl LoadedRuntime { + /// Load the Lua runtime from the system directory + pub fn load_lua(plugins_dir: &Path) -> PluginResult { + Self::load_from_path( + "Lua", + &PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so"), + b"owlry_lua_runtime_vtable", + plugins_dir, + ) + } + + /// Load a runtime from a specific path + fn load_from_path( + name: &'static str, + library_path: &Path, + vtable_symbol: &[u8], + plugins_dir: &Path, + ) -> 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 handle = (vtable.init)(RStr::from_str(&plugins_dir_str)); + + // Get provider information + let providers_rvec = (vtable.providers)(handle); + let providers: Vec = providers_rvec.into_iter().collect(); + + log::info!( + "Loaded {} runtime with {} provider(s)", + name, + providers.len() + ); + + Ok(Self { + name, + _library: 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, + self.handle, + info.clone(), + ); + Box::new(provider) as Box + }) + .collect() + } +} + +impl Drop for LoadedRuntime { + fn drop(&mut self) { + (self.vtable.drop)(self.handle); + } +} + +/// 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, + handle: RuntimeHandle, + info: ScriptProviderInfo, + items: Vec, +} + +impl RuntimeProvider { + fn new( + runtime_name: &'static str, + vtable: &'static ScriptRuntimeVTable, + handle: RuntimeHandle, + 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(), + } + } +} + +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 = (self.vtable.refresh)(self.handle, 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 + } +} + +// RuntimeProvider needs to be Send for the Provider trait +unsafe impl Send for RuntimeProvider {} + +/// 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) -> PluginResult { + Self::load_from_path( + "Rune", + &PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so"), + b"owlry_rune_runtime_vtable", + plugins_dir, + ) + } +} + +#[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/providers/application.rs b/crates/owlry-core/src/providers/application.rs new file mode 100644 index 0000000..3236e64 --- /dev/null +++ b/crates/owlry-core/src/providers/application.rs @@ -0,0 +1,266 @@ +use super::{LaunchItem, Provider, ProviderType}; +use crate::paths; +use freedesktop_desktop_entry::{DesktopEntry, Iter}; +use log::{debug, warn}; + +/// Clean desktop file field codes from command string. +/// Removes %f, %F, %u, %U, %d, %D, %n, %N, %i, %c, %k, %v, %m field codes +/// while preserving quoted arguments and %% (literal percent). +/// See: https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html +fn clean_desktop_exec_field(cmd: &str) -> String { + let mut result = String::with_capacity(cmd.len()); + let mut chars = cmd.chars().peekable(); + let mut in_single_quote = false; + let mut in_double_quote = false; + + while let Some(c) = chars.next() { + match c { + '\'' if !in_double_quote => { + in_single_quote = !in_single_quote; + result.push(c); + } + '"' if !in_single_quote => { + in_double_quote = !in_double_quote; + result.push(c); + } + '%' if !in_single_quote => { + // Check the next character for field code + if let Some(&next) = chars.peek() { + match next { + // Standard field codes to remove (with following space if present) + 'f' | 'F' | 'u' | 'U' | 'd' | 'D' | 'n' | 'N' | 'i' | 'c' | 'k' | 'v' + | 'm' => { + chars.next(); // consume the field code letter + // Skip trailing whitespace after the field code + while chars.peek() == Some(&' ') { + chars.next(); + } + } + // %% is escaped percent, output single % + '%' => { + chars.next(); + result.push('%'); + } + // Unknown % sequence, keep as-is + _ => { + result.push('%'); + } + } + } else { + // % at end of string, keep it + result.push('%'); + } + } + _ => { + result.push(c); + } + } + } + + // Clean up any double spaces that may have resulted from removing field codes + let mut cleaned = result.trim().to_string(); + while cleaned.contains(" ") { + cleaned = cleaned.replace(" ", " "); + } + + cleaned +} + +pub struct ApplicationProvider { + items: Vec, +} + +impl ApplicationProvider { + pub fn new() -> Self { + Self { items: Vec::new() } + } + + fn get_application_dirs() -> Vec { + paths::system_data_dirs() + } +} + +impl Provider for ApplicationProvider { + fn name(&self) -> &str { + "Applications" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Application + } + + fn refresh(&mut self) { + self.items.clear(); + + let dirs = Self::get_application_dirs(); + debug!("Scanning application directories: {:?}", dirs); + + // Empty locale list for default locale + let locales: &[&str] = &[]; + + // Get current desktop environment(s) for OnlyShowIn/NotShowIn filtering + // XDG_CURRENT_DESKTOP can be colon-separated (e.g., "ubuntu:GNOME") + let current_desktops: Vec = std::env::var("XDG_CURRENT_DESKTOP") + .unwrap_or_default() + .split(':') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + + for path in Iter::new(dirs.into_iter()) { + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + warn!("Failed to read {:?}: {}", path, e); + continue; + } + }; + + let desktop_entry = match DesktopEntry::from_str(&path, &content, Some(locales)) { + Ok(e) => e, + Err(e) => { + warn!("Failed to parse {:?}: {}", path, e); + continue; + } + }; + + // Skip entries marked as hidden or no-display + if desktop_entry.no_display() || desktop_entry.hidden() { + continue; + } + + // Only include Application type entries + if desktop_entry.type_() != Some("Application") { + continue; + } + + // Apply OnlyShowIn/NotShowIn filters only if we know the current desktop + // If XDG_CURRENT_DESKTOP is not set, show all apps (don't filter) + if !current_desktops.is_empty() { + // OnlyShowIn: if set, current desktop must be in the list + if desktop_entry.only_show_in().is_some_and(|only| { + !current_desktops.iter().any(|de| only.contains(&de.as_str())) + }) { + continue; + } + + // NotShowIn: if current desktop is in the list, skip + if desktop_entry.not_show_in().is_some_and(|not| { + current_desktops.iter().any(|de| not.contains(&de.as_str())) + }) { + continue; + } + } + + let name = match desktop_entry.name(locales) { + Some(n) => n.to_string(), + None => continue, + }; + + let run_cmd = match desktop_entry.exec() { + Some(e) => clean_desktop_exec_field(e), + None => continue, + }; + + // Extract categories and keywords as tags (lowercase for consistency) + let mut tags: Vec = desktop_entry + .categories() + .map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect()) + .unwrap_or_default(); + + // Add keywords for searchability (e.g., Nautilus has Name=Files but Keywords contains "nautilus") + if let Some(keywords) = desktop_entry.keywords(locales) { + tags.extend(keywords.into_iter().map(|s| s.to_lowercase())); + } + + let item = LaunchItem { + id: path.to_string_lossy().to_string(), + name, + description: desktop_entry.comment(locales).map(|s| s.to_string()), + icon: desktop_entry.icon().map(|s| s.to_string()), + provider: ProviderType::Application, + command: run_cmd, + terminal: desktop_entry.terminal(), + tags, + }; + + self.items.push(item); + } + + debug!("Found {} applications", self.items.len()); + + #[cfg(feature = "dev-logging")] + debug!( + "XDG_CURRENT_DESKTOP={:?}, scanned dirs count={}", + current_desktops, + Self::get_application_dirs().len() + ); + + // Sort alphabetically by name + self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clean_desktop_exec_simple() { + assert_eq!(clean_desktop_exec_field("firefox"), "firefox"); + assert_eq!(clean_desktop_exec_field("firefox %u"), "firefox"); + assert_eq!(clean_desktop_exec_field("code %F"), "code"); + } + + #[test] + fn test_clean_desktop_exec_multiple_placeholders() { + assert_eq!(clean_desktop_exec_field("app %f %u %U"), "app"); + assert_eq!(clean_desktop_exec_field("app --flag %u --other"), "app --flag --other"); + } + + #[test] + fn test_clean_desktop_exec_preserves_quotes() { + // Double quotes preserve spacing but field codes are still processed + assert_eq!( + clean_desktop_exec_field(r#"bash -c "echo hello""#), + r#"bash -c "echo hello""# + ); + // Field codes in double quotes are stripped (per FreeDesktop spec: undefined behavior, + // but practical implementations strip them) + assert_eq!( + clean_desktop_exec_field(r#"bash -c "test %u value""#), + r#"bash -c "test value""# + ); + } + + #[test] + fn test_clean_desktop_exec_escaped_percent() { + assert_eq!(clean_desktop_exec_field("echo 100%%"), "echo 100%"); + } + + #[test] + fn test_clean_desktop_exec_single_quotes() { + assert_eq!( + clean_desktop_exec_field("bash -c 'echo %u'"), + "bash -c 'echo %u'" + ); + } + + #[test] + fn test_clean_desktop_exec_preserves_env() { + // env VAR=value pattern should be preserved + assert_eq!( + clean_desktop_exec_field("env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity %F"), + "env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity" + ); + // Multiple env vars + assert_eq!( + clean_desktop_exec_field("env FOO=bar BAZ=qux myapp %u"), + "env FOO=bar BAZ=qux myapp" + ); + } +} diff --git a/crates/owlry-core/src/providers/command.rs b/crates/owlry-core/src/providers/command.rs new file mode 100644 index 0000000..0df024f --- /dev/null +++ b/crates/owlry-core/src/providers/command.rs @@ -0,0 +1,106 @@ +use super::{LaunchItem, Provider, ProviderType}; +use log::debug; +use std::collections::HashSet; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +pub struct CommandProvider { + items: Vec, +} + +impl CommandProvider { + pub fn new() -> Self { + Self { items: Vec::new() } + } + + fn get_path_dirs() -> Vec { + std::env::var("PATH") + .unwrap_or_default() + .split(':') + .map(PathBuf::from) + .filter(|p| p.exists()) + .collect() + } + + fn is_executable(path: &std::path::Path) -> bool { + if let Ok(metadata) = path.metadata() { + let permissions = metadata.permissions(); + permissions.mode() & 0o111 != 0 + } else { + false + } + } +} + +impl Provider for CommandProvider { + fn name(&self) -> &str { + "Commands" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Command + } + + fn refresh(&mut self) { + self.items.clear(); + + let dirs = Self::get_path_dirs(); + let mut seen_names: HashSet = HashSet::new(); + + debug!("Scanning PATH directories for commands"); + + for dir in dirs { + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + + // Skip directories and non-executable files + if path.is_dir() || !Self::is_executable(&path) { + continue; + } + + let name = match path.file_name() { + Some(n) => n.to_string_lossy().to_string(), + None => continue, + }; + + // Skip duplicates (first one in PATH wins) + if seen_names.contains(&name) { + continue; + } + seen_names.insert(name.clone()); + + // Skip hidden files + if name.starts_with('.') { + continue; + } + + let item = LaunchItem { + id: path.to_string_lossy().to_string(), + name: name.clone(), + description: Some(format!("Run {}", path.display())), + icon: Some("utilities-terminal".to_string()), + provider: ProviderType::Command, + command: name, + terminal: false, + tags: Vec::new(), + }; + + self.items.push(item); + } + } + + debug!("Found {} commands in PATH", self.items.len()); + + // Sort alphabetically + self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } +} diff --git a/crates/owlry-core/src/providers/lua_provider.rs b/crates/owlry-core/src/providers/lua_provider.rs new file mode 100644 index 0000000..d624846 --- /dev/null +++ b/crates/owlry-core/src/providers/lua_provider.rs @@ -0,0 +1,142 @@ +//! 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::cell::RefCell; +use std::rc::Rc; + +use crate::plugins::{LoadedPlugin, PluginItem, ProviderRegistration}; + +use super::{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) + plugin: Rc>, + /// Cached items from last refresh + items: Vec, +} + +impl LuaProvider { + /// Create a new LuaProvider + pub fn new(registration: ProviderRegistration, plugin: Rc>) -> Self { + Self { + registration, + plugin, + items: Vec::new(), + } + } + + /// Convert a PluginItem to a LaunchItem + fn convert_item(&self, item: PluginItem) -> LaunchItem { + 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, + } + } +} + +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.borrow(); + 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 needs to be Send for the Provider trait +// Since we're using Rc>, we need to be careful about thread safety +// For now, owlry is single-threaded, so this is safe +unsafe impl Send for LuaProvider {} + +/// Create LuaProviders from all registered providers in a plugin +pub fn create_providers_from_plugin( + plugin: Rc>, +) -> Vec> { + let registrations = { + let p = plugin.borrow(); + 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 new file mode 100644 index 0000000..3e6e472 --- /dev/null +++ b/crates/owlry-core/src/providers/mod.rs @@ -0,0 +1,598 @@ +// Core providers (no plugin equivalents) +mod application; +mod command; + +// 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 fuzzy_matcher::FuzzyMatcher; +use fuzzy_matcher::skim::SkimMatcherV2; +use log::info; + +#[cfg(feature = "dev-logging")] +use log::debug; + +use crate::data::FrecencyStore; + +/// 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, +} + +/// Provider type identifier for filtering and badge display +/// +/// Core types are built-in providers. All native plugins use Plugin(type_id). +/// This keeps the core app free of plugin-specific knowledge. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ProviderType { + /// Built-in: Desktop applications from XDG directories + Application, + /// Built-in: Shell commands from PATH + Command, + /// Built-in: Pipe-based input (dmenu compatibility) + Dmenu, + /// Plugin-defined provider type with its type_id (e.g., "calc", "weather", "emoji") + Plugin(String), +} + +impl std::str::FromStr for ProviderType { + type Err = String; + + 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), + "dmenu" => Ok(ProviderType::Dmenu), + // Everything else is a plugin + 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 +pub trait Provider: Send { + #[allow(dead_code)] + fn name(&self) -> &str; + fn provider_type(&self) -> ProviderType; + fn refresh(&mut self); + fn items(&self) -> &[LaunchItem]; +} + +/// Manages all providers and handles searching +pub struct ProviderManager { + /// Core static providers (apps, commands, dmenu) + providers: 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 + matcher: SkimMatcherV2, +} + +impl ProviderManager { + /// Create a new ProviderManager with core providers and native plugins. + /// + /// Core providers (e.g., ApplicationProvider, CommandProvider, DmenuProvider) are + /// passed in by the caller. Native plugins are categorized based on their declared + /// ProviderKind and ProviderPosition. + pub fn new( + core_providers: Vec>, + native_providers: Vec, + ) -> Self { + let mut manager = Self { + providers: core_providers, + static_native_providers: Vec::new(), + dynamic_providers: Vec::new(), + widget_providers: Vec::new(), + matcher: SkimMatcherV2::default(), + }; + + // 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 + } + + #[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) { + // Refresh core providers (apps, commands) + for provider in &mut self.providers { + provider.refresh(); + info!( + "Provider '{}' loaded {} items", + provider.name(), + 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) + } + + /// 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() + ); + } + } + + /// 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 + 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); + 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() + .take(max_results) + .map(|item| (item.clone(), 0)) + .collect(); + } + + let mut results: Vec<(LaunchItem, i64)> = self.all_static_items() + .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, 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 + pub fn search_filtered( + &self, + query: &str, + max_results: usize, + filter: &crate::filter::ProviderFilter, + ) -> Vec<(LaunchItem, i64)> { + // Collect items from core providers + let core_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()); + + if query.is_empty() { + return core_items + .chain(native_items) + .take(max_results) + .map(|item| (item, 0)) + .collect(); + } + + let mut results: Vec<(LaunchItem, i64)> = core_items + .chain(native_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 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) + if filter.active_prefix().is_none() && query.is_empty() { + // Widget priority comes from plugin-declared priority field + for provider in &self.widget_providers { + let base_score = provider.priority() as i64; + for (idx, item) in provider.items().iter().enumerate() { + results.push((item.clone(), base_score - idx as i64)); + } + } + } + + // 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) + 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; + for (idx, item) in dynamic_results.into_iter().enumerate() { + results.push((item, base_score - idx as i64)); + } + } + } + + // Empty query (after checking special providers) - return frecency-sorted items + if query.is_empty() { + // Collect items from core providers + let core_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 items: Vec<(LaunchItem, i64)> = core_items + .chain(native_items) + .filter(|item| { + // Apply tag filter if present + 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(&item.id); + let boosted = (frecency_score * frecency_weight * 100.0) as i64; + (item, boosted) + }) + .collect(); + + // Combine widgets (already in results) with frecency items + results.extend(items); + 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 + let score_item = |item: &LaunchItem| -> Option<(LaunchItem, i64)> { + // 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 + + 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(&item.id); + let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64; + (item.clone(), s + frecency_boost) + }) + }; + + // Search core providers + for provider in &self.providers { + if !filter.is_active(provider.provider_type()) { + continue; + } + for item in provider.items() { + if let Some(scored) = score_item(item) { + results.push(scored); + } + } + } + + // Search static native providers + for provider in &self.static_native_providers { + if !filter.is_active(provider.provider_type()) { + continue; + } + for item in provider.items() { + if let Some(scored) = score_item(item) { + results.push(scored); + } + } + } + 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_providers(&self) -> Vec { + self.providers + .iter() + .map(|p| p.provider_type()) + .chain(self.static_native_providers.iter().map(|p| p.provider_type())) + .collect() + } + + /// 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 + /// + /// 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. + 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 + ); + + // 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); + if !actions.is_empty() { + return Some((display_name.to_string(), actions)); + } + } + } + + #[cfg(feature = "dev-logging")] + debug!("[Submenu] No submenu actions found for plugin '{}'", plugin_id); + + None + } +} diff --git a/crates/owlry-core/src/providers/native_provider.rs b/crates/owlry-core/src/providers/native_provider.rs new file mode 100644 index 0000000..acda16b --- /dev/null +++ b/crates/owlry-core/src/providers/native_provider.rs @@ -0,0 +1,197 @@ +//! 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, RwLock}; + +use log::debug; +use owlry_plugin_api::{PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition}; + +use super::{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: RwLock>, +} + +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: RwLock::new(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()) + } + + /// 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(), + } + } + + /// 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.read().unwrap().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 + } + + /// 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.write().unwrap() = items; + } + + fn items(&self) -> &[LaunchItem] { + // This is tricky with RwLock - we need to return a reference but can't + // hold the lock across the return. We use a raw pointer approach. + // + // SAFETY: The items Vec is only modified during refresh() which takes + // &mut self, so no concurrent modification can occur while this + // reference is live. + unsafe { + let guard = self.items.read().unwrap(); + let ptr = guard.as_ptr(); + let len = guard.len(); + std::slice::from_raw_parts(ptr, len) + } + } +} + +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"); + } +} From 182a50059600c86f8d22bf66792b71376966f1f0 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 12:07:03 +0100 Subject: [PATCH 05/20] refactor: wire owlry to use owlry-core as library dependency - Add owlry-core dependency to owlry Cargo.toml - Remove dependencies from owlry that moved to owlry-core: fuzzy-matcher, freedesktop-desktop-entry, libloading, notify-rust, thiserror, mlua, meval, reqwest - Forward feature flags (dev-logging, lua) to owlry-core - Update all imports in owlry source files to use owlry_core:: for moved modules (config, data, filter, providers, plugins, notify, paths) - Delete original source files from owlry that were moved - Create minimal providers/mod.rs that only re-exports DmenuProvider - Move plugins/commands.rs to plugin_commands.rs (stays in owlry since it depends on CLI types from clap) - Restructure app.rs to build core providers externally and pass them to ProviderManager::new() instead of using the old with_native_plugins() constructor --- Cargo.lock | 19 +- crates/owlry-core/Cargo.toml | 6 + crates/owlry/Cargo.toml | 51 +- crates/owlry/src/app.rs | 45 +- crates/owlry/src/cli.rs | 2 +- crates/owlry/src/config/mod.rs | 574 ---------------- crates/owlry/src/data/frecency.rs | 219 ------- crates/owlry/src/data/mod.rs | 3 - crates/owlry/src/filter.rs | 409 ------------ crates/owlry/src/main.rs | 9 +- crates/owlry/src/notify.rs | 91 --- crates/owlry/src/paths.rs | 203 ------ .../commands.rs => plugin_commands.rs} | 14 +- crates/owlry/src/plugins/api/action.rs | 322 --------- crates/owlry/src/plugins/api/cache.rs | 299 --------- crates/owlry/src/plugins/api/hook.rs | 410 ------------ crates/owlry/src/plugins/api/http.rs | 345 ---------- crates/owlry/src/plugins/api/math.rs | 181 ----- crates/owlry/src/plugins/api/mod.rs | 77 --- crates/owlry/src/plugins/api/process.rs | 207 ------ crates/owlry/src/plugins/api/provider.rs | 315 --------- crates/owlry/src/plugins/api/theme.rs | 275 -------- crates/owlry/src/plugins/api/utils.rs | 567 ---------------- crates/owlry/src/plugins/error.rs | 51 -- crates/owlry/src/plugins/loader.rs | 205 ------ crates/owlry/src/plugins/manifest.rs | 318 --------- crates/owlry/src/plugins/mod.rs | 337 ---------- crates/owlry/src/plugins/native_loader.rs | 391 ----------- crates/owlry/src/plugins/registry.rs | 293 --------- crates/owlry/src/plugins/runtime.rs | 153 ----- crates/owlry/src/plugins/runtime_loader.rs | 286 -------- crates/owlry/src/providers/application.rs | 266 -------- crates/owlry/src/providers/command.rs | 106 --- crates/owlry/src/providers/dmenu.rs | 2 +- crates/owlry/src/providers/lua_provider.rs | 142 ---- crates/owlry/src/providers/mod.rs | 616 +----------------- crates/owlry/src/providers/native_provider.rs | 197 ------ crates/owlry/src/theme.rs | 2 +- crates/owlry/src/ui/main_window.rs | 16 +- crates/owlry/src/ui/result_row.rs | 10 +- crates/owlry/src/ui/submenu.rs | 4 +- 41 files changed, 92 insertions(+), 7946 deletions(-) delete mode 100644 crates/owlry/src/config/mod.rs delete mode 100644 crates/owlry/src/data/frecency.rs delete mode 100644 crates/owlry/src/data/mod.rs delete mode 100644 crates/owlry/src/filter.rs delete mode 100644 crates/owlry/src/notify.rs delete mode 100644 crates/owlry/src/paths.rs rename crates/owlry/src/{plugins/commands.rs => plugin_commands.rs} (98%) delete mode 100644 crates/owlry/src/plugins/api/action.rs delete mode 100644 crates/owlry/src/plugins/api/cache.rs delete mode 100644 crates/owlry/src/plugins/api/hook.rs delete mode 100644 crates/owlry/src/plugins/api/http.rs delete mode 100644 crates/owlry/src/plugins/api/math.rs delete mode 100644 crates/owlry/src/plugins/api/mod.rs delete mode 100644 crates/owlry/src/plugins/api/process.rs delete mode 100644 crates/owlry/src/plugins/api/provider.rs delete mode 100644 crates/owlry/src/plugins/api/theme.rs delete mode 100644 crates/owlry/src/plugins/api/utils.rs delete mode 100644 crates/owlry/src/plugins/error.rs delete mode 100644 crates/owlry/src/plugins/loader.rs delete mode 100644 crates/owlry/src/plugins/manifest.rs delete mode 100644 crates/owlry/src/plugins/mod.rs delete mode 100644 crates/owlry/src/plugins/native_loader.rs delete mode 100644 crates/owlry/src/plugins/registry.rs delete mode 100644 crates/owlry/src/plugins/runtime.rs delete mode 100644 crates/owlry/src/plugins/runtime_loader.rs delete mode 100644 crates/owlry/src/providers/application.rs delete mode 100644 crates/owlry/src/providers/command.rs delete mode 100644 crates/owlry/src/providers/lua_provider.rs delete mode 100644 crates/owlry/src/providers/native_provider.rs diff --git a/Cargo.lock b/Cargo.lock index ed4b218..130d0ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2439,12 +2439,27 @@ dependencies = [ "clap", "dirs", "env_logger", - "freedesktop-desktop-entry", - "fuzzy-matcher", "glib-build-tools", "gtk4", "gtk4-layer-shell", "libc", + "log", + "owlry-core", + "semver", + "serde", + "serde_json", + "toml 0.8.23", +] + +[[package]] +name = "owlry-core" +version = "0.5.0" +dependencies = [ + "chrono", + "dirs", + "env_logger", + "freedesktop-desktop-entry", + "fuzzy-matcher", "libloading 0.8.9", "log", "meval", diff --git a/crates/owlry-core/Cargo.toml b/crates/owlry-core/Cargo.toml index 0865c10..a6e5b40 100644 --- a/crates/owlry-core/Cargo.toml +++ b/crates/owlry-core/Cargo.toml @@ -29,6 +29,9 @@ toml = "0.8" chrono = { version = "0.4", features = ["serde"] } dirs = "5" +# Error handling +thiserror = "2" + # Logging & notifications log = "0.4" env_logger = "0.11" @@ -39,6 +42,9 @@ mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"] meval = { version = "0.2", optional = true } reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"], optional = true } +[dev-dependencies] +tempfile = "3" + [features] default = [] lua = ["dep:mlua", "dep:meval", "dep:reqwest"] diff --git a/crates/owlry/Cargo.toml b/crates/owlry/Cargo.toml index 7774f5a..35b89ca 100644 --- a/crates/owlry/Cargo.toml +++ b/crates/owlry/Cargo.toml @@ -11,8 +11,8 @@ keywords = ["launcher", "wayland", "gtk4", "linux"] categories = ["gui"] [dependencies] -# Shared plugin API -owlry-plugin-api = { path = "../owlry-plugin-api" } +# Core backend library +owlry-core = { path = "../owlry-core" } # GTK4 for the UI gtk4 = { version = "0.10", features = ["v4_12"] } @@ -20,60 +20,32 @@ gtk4 = { version = "0.10", features = ["v4_12"] } # Layer shell support for Wayland overlay behavior gtk4-layer-shell = "0.7" -# Fuzzy matching for search -fuzzy-matcher = "0.3" - -# XDG desktop entry parsing -freedesktop-desktop-entry = "0.8" - -# Directory utilities -dirs = "5" - -# Low-level syscalls for stdin detection +# Low-level syscalls for stdin detection (dmenu mode) libc = "0.2" # Logging log = "0.4" env_logger = "0.11" -# Error handling -thiserror = "2" - -# Configuration +# Configuration (needed for config types used in app.rs/theme.rs) serde = { version = "1", features = ["derive"] } toml = "0.8" # CLI argument parsing clap = { version = "4", features = ["derive"] } -# Math expression evaluation (for Lua plugins) -meval = { version = "0.2", optional = true } - -# JSON serialization for data persistence +# JSON serialization (needed by plugin commands in CLI) serde_json = "1" -# Date/time for frecency calculations +# Date/time (needed by plugin commands in CLI) chrono = { version = "0.4", features = ["serde"] } -# HTTP client (for Lua plugins) -reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"], optional = true } +# Directory utilities (needed by plugin commands) +dirs = "5" -# Lua runtime for plugin system (optional - can be loaded dynamically via owlry-lua) -mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true } - -# Semantic versioning for plugin compatibility +# Semantic versioning (needed by plugin commands) semver = "1" -# Dynamic library loading for native plugins -libloading = "0.8" - -# Desktop notifications (freedesktop notification spec) -notify-rust = "4" - -[dev-dependencies] -# Temporary directories for tests -tempfile = "3" - [build-dependencies] # GResource compilation for bundled icons glib-build-tools = "0.20" @@ -81,7 +53,6 @@ glib-build-tools = "0.20" [features] default = [] # Enable verbose debug logging (for development/testing builds) -dev-logging = [] +dev-logging = ["owlry-core/dev-logging"] # Enable built-in Lua runtime (disable to use external owlry-lua package) -# Includes: mlua, meval (math), reqwest (http) -lua = ["dep:mlua", "dep:meval", "dep:reqwest"] +lua = ["owlry-core/lua"] diff --git a/crates/owlry/src/app.rs b/crates/owlry/src/app.rs index 8679a5a..47836a2 100644 --- a/crates/owlry/src/app.rs +++ b/crates/owlry/src/app.rs @@ -1,16 +1,17 @@ use crate::cli::CliArgs; -use crate::config::Config; -use crate::data::FrecencyStore; -use crate::filter::ProviderFilter; -use crate::paths; -use crate::plugins::native_loader::NativePluginLoader; -#[cfg(feature = "lua")] -use crate::plugins::PluginManager; -use crate::providers::native_provider::NativeProvider; -use crate::providers::Provider; // For name() method -use crate::providers::ProviderManager; +use crate::providers::DmenuProvider; use crate::theme; use crate::ui::MainWindow; +use owlry_core::config::Config; +use owlry_core::data::FrecencyStore; +use owlry_core::filter::ProviderFilter; +use owlry_core::paths; +use owlry_core::plugins::native_loader::NativePluginLoader; +#[cfg(feature = "lua")] +use owlry_core::plugins::PluginManager; +use owlry_core::providers::native_provider::NativeProvider; +use owlry_core::providers::Provider; // For name() method +use owlry_core::providers::{ApplicationProvider, CommandProvider, ProviderManager}; use gtk4::prelude::*; use gtk4::{gio, Application, CssProvider}; use gtk4_layer_shell::{Edge, Layer, LayerShell}; @@ -55,11 +56,25 @@ impl OwlryApp { // Load native plugins from /usr/lib/owlry/plugins/ let native_providers = Self::load_native_plugins(&config.borrow()); - // Create provider manager with native plugins + // Build core providers based on mode + let dmenu_mode = DmenuProvider::has_stdin_data(); + let core_providers: Vec> = if dmenu_mode { + let mut dmenu = DmenuProvider::new(); + dmenu.enable(); + vec![Box::new(dmenu)] + } else { + vec![ + Box::new(ApplicationProvider::new()), + Box::new(CommandProvider::new()), + ] + }; + + // Create provider manager with core providers and native plugins + let native_for_manager = if dmenu_mode { Vec::new() } else { native_providers }; #[cfg(feature = "lua")] - let mut provider_manager = ProviderManager::with_native_plugins(native_providers); + let mut provider_manager = ProviderManager::new(core_providers, native_for_manager); #[cfg(not(feature = "lua"))] - let provider_manager = ProviderManager::with_native_plugins(native_providers); + let provider_manager = ProviderManager::new(core_providers, native_for_manager); // Load Lua plugins if enabled (requires lua feature) #[cfg(feature = "lua")] @@ -117,7 +132,7 @@ impl OwlryApp { Ok(count) => { if count == 0 { debug!("No native plugins found in {}", - crate::plugins::native_loader::SYSTEM_PLUGINS_DIR); + owlry_core::plugins::native_loader::SYSTEM_PLUGINS_DIR); return Vec::new(); } info!("Discovered {} native plugin(s)", count); @@ -129,7 +144,7 @@ impl OwlryApp { } // Get all plugins and create providers - let plugins: Vec> = + let plugins: Vec> = loader.into_plugins(); // Create NativeProvider instances from loaded plugins diff --git a/crates/owlry/src/cli.rs b/crates/owlry/src/cli.rs index 2090fe9..1b345da 100644 --- a/crates/owlry/src/cli.rs +++ b/crates/owlry/src/cli.rs @@ -4,7 +4,7 @@ use clap::{Parser, Subcommand}; -use crate::providers::ProviderType; +use owlry_core::providers::ProviderType; #[derive(Parser, Debug, Clone)] #[command( diff --git a/crates/owlry/src/config/mod.rs b/crates/owlry/src/config/mod.rs deleted file mode 100644 index dc6a57f..0000000 --- a/crates/owlry/src/config/mod.rs +++ /dev/null @@ -1,574 +0,0 @@ -use log::{debug, info, warn}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; -use std::process::Command; - -use crate::paths; - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct Config { - #[serde(default)] - pub general: GeneralConfig, - #[serde(default)] - pub appearance: AppearanceConfig, - #[serde(default)] - pub providers: ProvidersConfig, - #[serde(default)] - pub plugins: PluginsConfig, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeneralConfig { - #[serde(default = "default_true")] - pub show_icons: bool, - #[serde(default = "default_max_results")] - pub max_results: usize, - /// Terminal command (auto-detected if not specified) - #[serde(default)] - pub terminal_command: Option, - /// Enable uwsm (Universal Wayland Session Manager) for launching apps. - /// When enabled, desktop files are launched via `uwsm app -- ` - /// which starts apps in a proper systemd user session. - /// When disabled (default), apps are launched via `gio launch`. - #[serde(default)] - pub use_uwsm: bool, - /// Provider tabs shown in the header bar. - /// Valid values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web - #[serde(default = "default_tabs")] - pub tabs: Vec, -} - -impl Default for GeneralConfig { - fn default() -> Self { - Self { - show_icons: true, - max_results: 100, - terminal_command: None, - use_uwsm: false, - tabs: default_tabs(), - } - } -} - -fn default_max_results() -> usize { - 100 -} - -fn default_tabs() -> Vec { - vec![ - "app".to_string(), - "cmd".to_string(), - "uuctl".to_string(), - ] -} - -/// User-customizable theme colors -/// All fields are optional - unset values inherit from theme or GTK defaults -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ThemeColors { - // Core colors - pub background: Option, - pub background_secondary: Option, - pub border: Option, - pub text: Option, - pub text_secondary: Option, - pub accent: Option, - pub accent_bright: Option, - // Provider badge colors - pub badge_app: Option, - pub badge_bookmark: Option, - pub badge_calc: Option, - pub badge_clip: Option, - pub badge_cmd: Option, - pub badge_dmenu: Option, - pub badge_emoji: Option, - pub badge_file: Option, - pub badge_script: Option, - pub badge_ssh: Option, - pub badge_sys: Option, - pub badge_uuctl: Option, - pub badge_web: Option, - // Widget badge colors - pub badge_media: Option, - pub badge_weather: Option, - pub badge_pomo: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AppearanceConfig { - #[serde(default = "default_width")] - pub width: i32, - #[serde(default = "default_height")] - pub height: i32, - #[serde(default = "default_font_size")] - pub font_size: u32, - #[serde(default = "default_border_radius")] - pub border_radius: u32, - /// Theme name: None = GTK default, "owl" = built-in owl theme - #[serde(default)] - pub theme: Option, - /// Individual color overrides - #[serde(default)] - pub colors: ThemeColors, -} - -impl Default for AppearanceConfig { - fn default() -> Self { - Self { - width: 850, - height: 650, - font_size: 14, - border_radius: 12, - theme: None, - colors: ThemeColors::default(), - } - } -} - -fn default_width() -> i32 { 850 } -fn default_height() -> i32 { 650 } -fn default_font_size() -> u32 { 14 } -fn default_border_radius() -> u32 { 12 } - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProvidersConfig { - #[serde(default = "default_true")] - pub applications: bool, - #[serde(default = "default_true")] - pub commands: bool, - #[serde(default = "default_true")] - pub uuctl: bool, - /// Enable calculator provider (= expression or calc expression) - #[serde(default = "default_true")] - pub calculator: bool, - /// Enable frecency-based result ranking - #[serde(default = "default_true")] - pub frecency: bool, - /// Weight for frecency boost (0.0 = disabled, 1.0 = strong boost) - #[serde(default = "default_frecency_weight")] - pub frecency_weight: f64, - /// Enable web search provider (? query or web query) - #[serde(default = "default_true")] - pub websearch: bool, - /// Search engine for web search - /// Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia - /// Or custom URL with {query} placeholder - #[serde(default = "default_search_engine")] - pub search_engine: String, - /// Enable system commands (shutdown, reboot, etc.) - #[serde(default = "default_true")] - pub system: bool, - /// Enable SSH connections from ~/.ssh/config - #[serde(default = "default_true")] - pub ssh: bool, - /// Enable clipboard history (requires cliphist) - #[serde(default = "default_true")] - pub clipboard: bool, - /// Enable browser bookmarks - #[serde(default = "default_true")] - pub bookmarks: bool, - /// Enable emoji picker - #[serde(default = "default_true")] - pub emoji: bool, - /// Enable custom scripts from ~/.config/owlry/scripts/ - #[serde(default = "default_true")] - pub scripts: bool, - /// Enable file search (requires fd or locate) - #[serde(default = "default_true")] - pub files: bool, - - // ─── Widget Providers ─────────────────────────────────────────────── - - /// Enable MPRIS media player widget - #[serde(default = "default_true")] - pub media: bool, - - /// Enable weather widget - #[serde(default)] - pub weather: bool, - - /// Weather provider: wttr.in (default), openweathermap, open-meteo - #[serde(default = "default_weather_provider")] - pub weather_provider: String, - - /// API key for weather services that require it (e.g., OpenWeatherMap) - #[serde(default)] - pub weather_api_key: Option, - - /// Location for weather (city name or coordinates) - #[serde(default)] - pub weather_location: Option, - - /// Enable pomodoro timer widget - #[serde(default)] - pub pomodoro: bool, - - /// Pomodoro work duration in minutes - #[serde(default = "default_pomodoro_work")] - pub pomodoro_work_mins: u32, - - /// Pomodoro break duration in minutes - #[serde(default = "default_pomodoro_break")] - pub pomodoro_break_mins: u32, -} - -impl Default for ProvidersConfig { - fn default() -> Self { - Self { - applications: true, - commands: true, - uuctl: true, - calculator: true, - frecency: true, - frecency_weight: 0.3, - websearch: true, - search_engine: "duckduckgo".to_string(), - system: true, - ssh: true, - clipboard: true, - bookmarks: true, - emoji: true, - scripts: true, - files: true, - media: true, - weather: false, - weather_provider: "wttr.in".to_string(), - weather_api_key: None, - weather_location: Some("Berlin".to_string()), - pomodoro: false, - pomodoro_work_mins: 25, - pomodoro_break_mins: 5, - } - } -} - -/// Configuration for plugins -/// -/// Supports per-plugin configuration via `[plugins.]` sections: -/// ```toml -/// [plugins] -/// enabled = true -/// -/// [plugins.weather] -/// location = "Berlin" -/// units = "metric" -/// -/// [plugins.pomodoro] -/// work_mins = 25 -/// break_mins = 5 -/// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginsConfig { - /// Whether plugins are enabled globally - #[serde(default = "default_true")] - pub enabled: bool, - - /// List of plugin IDs to enable (empty = all discovered plugins) - #[serde(default)] - pub enabled_plugins: Vec, - - /// List of plugin IDs to explicitly disable - #[serde(default)] - pub disabled_plugins: Vec, - - /// Sandbox settings for plugin execution - #[serde(default)] - pub sandbox: SandboxConfig, - - /// Plugin registry URL (for `owlry plugin search` and registry installs) - /// Defaults to the official owlry plugin registry if not specified. - #[serde(default)] - pub registry_url: Option, - - /// Per-plugin configuration tables - /// Accessed via `[plugins.]` sections in config.toml - /// Each plugin can define its own config schema - #[serde(flatten)] - pub plugin_configs: HashMap, -} - -/// Sandbox settings for plugin security -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SandboxConfig { - /// Allow plugins to access the filesystem (beyond their own directory) - #[serde(default)] - pub allow_filesystem: bool, - - /// Allow plugins to make network requests - #[serde(default)] - pub allow_network: bool, - - /// Allow plugins to run shell commands - #[serde(default)] - pub allow_commands: bool, - - /// Memory limit for Lua runtime in bytes (0 = unlimited) - #[serde(default = "default_memory_limit")] - pub memory_limit: usize, -} - -impl Default for PluginsConfig { - fn default() -> Self { - Self { - enabled: true, - enabled_plugins: Vec::new(), - disabled_plugins: Vec::new(), - sandbox: SandboxConfig::default(), - registry_url: None, - plugin_configs: HashMap::new(), - } - } -} - -impl PluginsConfig { - /// Get configuration for a specific plugin by name - /// - /// Returns the plugin's config table if it exists in `[plugins.]` - #[allow(dead_code)] - pub fn get_plugin_config(&self, plugin_name: &str) -> Option<&toml::Value> { - self.plugin_configs.get(plugin_name) - } - - /// Get a string value from a plugin's config - #[allow(dead_code)] - pub fn get_plugin_string(&self, plugin_name: &str, key: &str) -> Option<&str> { - self.plugin_configs - .get(plugin_name)? - .get(key)? - .as_str() - } - - /// Get an integer value from a plugin's config - #[allow(dead_code)] - pub fn get_plugin_int(&self, plugin_name: &str, key: &str) -> Option { - self.plugin_configs - .get(plugin_name)? - .get(key)? - .as_integer() - } - - /// Get a boolean value from a plugin's config - #[allow(dead_code)] - pub fn get_plugin_bool(&self, plugin_name: &str, key: &str) -> Option { - self.plugin_configs - .get(plugin_name)? - .get(key)? - .as_bool() - } -} - -impl Default for SandboxConfig { - fn default() -> Self { - Self { - allow_filesystem: false, - allow_network: false, - allow_commands: false, - memory_limit: default_memory_limit(), - } - } -} - -fn default_memory_limit() -> usize { - 64 * 1024 * 1024 // 64 MB -} - -fn default_search_engine() -> String { - "duckduckgo".to_string() -} - -fn default_true() -> bool { - true -} - -fn default_frecency_weight() -> f64 { - 0.3 -} - -fn default_weather_provider() -> String { - "wttr.in".to_string() -} - -fn default_pomodoro_work() -> u32 { - 25 -} - -fn default_pomodoro_break() -> u32 { - 5 -} - - -/// Detect the best available terminal emulator -/// Fallback chain: -/// 1. $TERMINAL env var (user's explicit preference) -/// 2. xdg-terminal-exec (freedesktop standard - if available) -/// 3. Desktop-environment native terminal (GNOME→gnome-terminal, KDE→konsole, etc.) -/// 4. Common Wayland-native terminals (kitty, alacritty, wezterm, foot) -/// 5. Common X11/legacy terminals -/// 6. x-terminal-emulator (Debian alternatives) -/// 7. xterm (ultimate fallback - the cockroach of terminals) -fn detect_terminal() -> String { - // 1. Check $TERMINAL env var first (user's explicit preference) - if let Ok(term) = std::env::var("TERMINAL") - && !term.is_empty() && command_exists(&term) { - debug!("Using $TERMINAL: {}", term); - return term; - } - - // 2. Try xdg-terminal-exec (freedesktop standard) - if command_exists("xdg-terminal-exec") { - debug!("Using xdg-terminal-exec"); - return "xdg-terminal-exec".to_string(); - } - - // 3. Desktop-environment aware detection - if let Some(term) = detect_de_terminal() { - debug!("Using DE-native terminal: {}", term); - return term; - } - - // 4. Common Wayland-native terminals (preferred for modern setups) - let wayland_terminals = ["kitty", "alacritty", "wezterm", "foot"]; - for term in wayland_terminals { - if command_exists(term) { - debug!("Found Wayland terminal: {}", term); - return term.to_string(); - } - } - - // 5. Common X11/legacy terminals - let legacy_terminals = ["gnome-terminal", "konsole", "xfce4-terminal", "mate-terminal", "tilix", "terminator"]; - for term in legacy_terminals { - if command_exists(term) { - debug!("Found legacy terminal: {}", term); - return term.to_string(); - } - } - - // 6. Try x-terminal-emulator (Debian alternatives system) - if command_exists("x-terminal-emulator") { - debug!("Using x-terminal-emulator"); - return "x-terminal-emulator".to_string(); - } - - // 7. Ultimate fallback - xterm exists everywhere - debug!("Falling back to xterm"); - "xterm".to_string() -} - -/// Detect desktop environment and return its native terminal -fn detect_de_terminal() -> Option { - // Check XDG_CURRENT_DESKTOP first - let desktop = std::env::var("XDG_CURRENT_DESKTOP") - .ok() - .map(|s| s.to_lowercase()); - - // Also check for Wayland compositor-specific env vars - let is_hyprland = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok(); - let is_sway = std::env::var("SWAYSOCK").is_ok(); - - // Map desktop environments to their native/preferred terminals - let candidates: &[&str] = if is_hyprland { - // Hyprland: foot and kitty are most popular in the community - &["foot", "kitty", "alacritty", "wezterm"] - } else if is_sway { - // Sway: foot is the recommended terminal (lightweight, Wayland-native) - &["foot", "alacritty", "kitty", "wezterm"] - } else if let Some(ref de) = desktop { - match de.as_str() { - s if s.contains("gnome") => &["gnome-terminal", "gnome-console", "kgx"], - s if s.contains("kde") || s.contains("plasma") => &["konsole"], - s if s.contains("xfce") => &["xfce4-terminal"], - s if s.contains("mate") => &["mate-terminal"], - s if s.contains("lxqt") => &["qterminal"], - s if s.contains("lxde") => &["lxterminal"], - s if s.contains("cinnamon") => &["gnome-terminal"], - s if s.contains("budgie") => &["tilix", "gnome-terminal"], - s if s.contains("pantheon") => &["io.elementary.terminal", "pantheon-terminal"], - s if s.contains("deepin") => &["deepin-terminal"], - s if s.contains("hyprland") => &["foot", "kitty", "alacritty", "wezterm"], - s if s.contains("sway") => &["foot", "alacritty", "kitty", "wezterm"], - _ => return None, - } - } else { - return None; - }; - - for term in candidates { - if command_exists(term) { - return Some(term.to_string()); - } - } - - None -} - -/// Check if a command exists in PATH -fn command_exists(cmd: &str) -> bool { - Command::new("which") - .arg(cmd) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) -} - -// Note: Config derives Default via #[derive(Default)] - all sub-structs have impl Default - -impl Config { - pub fn config_path() -> Option { - paths::config_file() - } - - pub fn load_or_default() -> Self { - Self::load().unwrap_or_else(|e| { - warn!("Failed to load config: {}, using defaults", e); - Self::default() - }) - } - - pub fn load() -> Result> { - let path = Self::config_path().ok_or("Could not determine config path")?; - - let mut config = if !path.exists() { - info!("Config file not found, using defaults"); - Self::default() - } else { - let content = std::fs::read_to_string(&path)?; - let config: Config = toml::from_str(&content)?; - info!("Loaded config from {:?}", path); - config - }; - - // Auto-detect terminal if not configured or configured terminal doesn't exist - match &config.general.terminal_command { - None => { - let terminal = detect_terminal(); - info!("Detected terminal: {}", terminal); - config.general.terminal_command = Some(terminal); - } - Some(term) if !command_exists(term) => { - warn!("Configured terminal '{}' not found, auto-detecting", term); - let terminal = detect_terminal(); - info!("Using detected terminal: {}", terminal); - config.general.terminal_command = Some(terminal); - } - Some(term) => { - debug!("Using configured terminal: {}", term); - } - } - - Ok(config) - } - - #[allow(dead_code)] - pub fn save(&self) -> Result<(), Box> { - let path = Self::config_path().ok_or("Could not determine config path")?; - - paths::ensure_parent_dir(&path)?; - - let content = toml::to_string_pretty(self)?; - std::fs::write(&path, content)?; - info!("Saved config to {:?}", path); - Ok(()) - } -} diff --git a/crates/owlry/src/data/frecency.rs b/crates/owlry/src/data/frecency.rs deleted file mode 100644 index af43413..0000000 --- a/crates/owlry/src/data/frecency.rs +++ /dev/null @@ -1,219 +0,0 @@ -use chrono::{DateTime, Utc}; -use log::{debug, info, warn}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; - -use crate::paths; - -/// A single frecency entry tracking launch count and recency -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FrecencyEntry { - pub launch_count: u32, - pub last_launch: DateTime, -} - -/// Persistent frecency data store -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FrecencyData { - pub version: u32, - pub entries: HashMap, -} - -impl Default for FrecencyData { - fn default() -> Self { - Self { - version: 1, - entries: HashMap::new(), - } - } -} - -/// Frecency store for tracking and boosting recently/frequently used items -pub struct FrecencyStore { - data: FrecencyData, - path: PathBuf, - dirty: bool, -} - -impl FrecencyStore { - /// Create a new frecency store, loading existing data if available - pub fn new() -> Self { - let path = Self::data_path(); - let data = Self::load_from_path(&path).unwrap_or_default(); - - info!("Frecency store loaded with {} entries", data.entries.len()); - - Self { - data, - path, - dirty: false, - } - } - - /// Alias for new() - loads from disk or creates default - pub fn load_or_default() -> Self { - Self::new() - } - - /// Get the path to the frecency data file - fn data_path() -> PathBuf { - paths::frecency_file().unwrap_or_else(|| PathBuf::from("frecency.json")) - } - - /// Load frecency data from a file - fn load_from_path(path: &PathBuf) -> Option { - if !path.exists() { - debug!("Frecency file not found at {:?}", path); - return None; - } - - let content = std::fs::read_to_string(path).ok()?; - match serde_json::from_str(&content) { - Ok(data) => Some(data), - Err(e) => { - warn!("Failed to parse frecency data: {}", e); - None - } - } - } - - /// Save frecency data to disk - pub fn save(&mut self) -> Result<(), Box> { - if !self.dirty { - return Ok(()); - } - - paths::ensure_parent_dir(&self.path)?; - - let content = serde_json::to_string_pretty(&self.data)?; - std::fs::write(&self.path, content)?; - self.dirty = false; - - debug!("Frecency data saved to {:?}", self.path); - Ok(()) - } - - /// Record a launch event for an item - pub fn record_launch(&mut self, item_id: &str) { - let now = Utc::now(); - - let entry = self - .data - .entries - .entry(item_id.to_string()) - .or_insert(FrecencyEntry { - launch_count: 0, - last_launch: now, - }); - - entry.launch_count += 1; - entry.last_launch = now; - self.dirty = true; - - debug!( - "Recorded launch for '{}': count={}, last={}", - item_id, entry.launch_count, entry.last_launch - ); - - // Auto-save after recording - if let Err(e) = self.save() { - warn!("Failed to save frecency data: {}", e); - } - } - - /// Calculate frecency score for an item - /// Uses Firefox-style algorithm: score = launch_count * recency_weight - pub fn get_score(&self, item_id: &str) -> f64 { - match self.data.entries.get(item_id) { - Some(entry) => Self::calculate_frecency(entry.launch_count, entry.last_launch), - None => 0.0, - } - } - - /// Calculate frecency using Firefox-style algorithm - fn calculate_frecency(launch_count: u32, last_launch: DateTime) -> f64 { - let now = Utc::now(); - let age = now.signed_duration_since(last_launch); - let age_days = age.num_hours() as f64 / 24.0; - - // Recency weight based on how recently the item was used - let recency_weight = if age_days < 1.0 { - 100.0 // Today - } else if age_days < 7.0 { - 70.0 // This week - } else if age_days < 30.0 { - 50.0 // This month - } else if age_days < 90.0 { - 30.0 // This quarter - } else { - 10.0 // Older - }; - - launch_count as f64 * recency_weight - } - - /// Get all entries (for debugging/display) - #[allow(dead_code)] - pub fn entries(&self) -> &HashMap { - &self.data.entries - } - - /// Clear all frecency data - #[allow(dead_code)] - pub fn clear(&mut self) { - self.data.entries.clear(); - self.dirty = true; - } -} - -impl Default for FrecencyStore { - fn default() -> Self { - Self::new() - } -} - -impl Drop for FrecencyStore { - fn drop(&mut self) { - // Attempt to save on drop - if let Err(e) = self.save() { - warn!("Failed to save frecency data on drop: {}", e); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_frecency_calculation() { - let now = Utc::now(); - - // Recent launch should have high score - let score_today = FrecencyStore::calculate_frecency(10, now); - assert!(score_today > 900.0); // 10 * 100 - - // Older launch should have lower score - let week_ago = now - chrono::Duration::days(5); - let score_week = FrecencyStore::calculate_frecency(10, week_ago); - assert!(score_week < score_today); - assert!(score_week > 600.0); // 10 * 70 - - // Much older launch - let month_ago = now - chrono::Duration::days(45); - let score_month = FrecencyStore::calculate_frecency(10, month_ago); - assert!(score_month < score_week); - } - - #[test] - fn test_launch_count_matters() { - let now = Utc::now(); - - let score_few = FrecencyStore::calculate_frecency(2, now); - let score_many = FrecencyStore::calculate_frecency(20, now); - - assert!(score_many > score_few); - assert!((score_many / score_few - 10.0).abs() < 0.1); // Should be ~10x - } -} diff --git a/crates/owlry/src/data/mod.rs b/crates/owlry/src/data/mod.rs deleted file mode 100644 index 8fc1d1b..0000000 --- a/crates/owlry/src/data/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod frecency; - -pub use frecency::FrecencyStore; diff --git a/crates/owlry/src/filter.rs b/crates/owlry/src/filter.rs deleted file mode 100644 index b9e231e..0000000 --- a/crates/owlry/src/filter.rs +++ /dev/null @@ -1,409 +0,0 @@ -use std::collections::HashSet; - -#[cfg(feature = "dev-logging")] -use log::debug; - -use crate::config::ProvidersConfig; -use crate::providers::ProviderType; - -/// Tracks which providers are enabled and handles prefix-based filtering -#[derive(Debug, Clone)] -pub struct ProviderFilter { - enabled: HashSet, - active_prefix: Option, -} - -/// Result of parsing a query for prefix syntax -#[derive(Debug, Clone)] -pub struct ParsedQuery { - pub prefix: Option, - pub tag_filter: Option, - pub query: String, -} - -impl ProviderFilter { - /// Create filter from CLI args and config - pub fn new( - cli_mode: Option, - cli_providers: Option>, - config_providers: &ProvidersConfig, - ) -> Self { - let enabled = if let Some(mode) = cli_mode { - // --mode overrides everything: single provider - HashSet::from([mode]) - } else if let Some(providers) = cli_providers { - // --providers overrides config - providers.into_iter().collect() - } else { - // Use config file settings, default to apps only - let mut set = HashSet::new(); - // Core providers - if config_providers.applications { - set.insert(ProviderType::Application); - } - if config_providers.commands { - set.insert(ProviderType::Command); - } - // Plugin providers - use Plugin(type_id) for all - if config_providers.uuctl { - set.insert(ProviderType::Plugin("uuctl".to_string())); - } - if config_providers.system { - set.insert(ProviderType::Plugin("system".to_string())); - } - if config_providers.ssh { - set.insert(ProviderType::Plugin("ssh".to_string())); - } - if config_providers.clipboard { - set.insert(ProviderType::Plugin("clipboard".to_string())); - } - if config_providers.bookmarks { - set.insert(ProviderType::Plugin("bookmarks".to_string())); - } - if config_providers.emoji { - set.insert(ProviderType::Plugin("emoji".to_string())); - } - if config_providers.scripts { - set.insert(ProviderType::Plugin("scripts".to_string())); - } - // Dynamic providers - if config_providers.files { - set.insert(ProviderType::Plugin("filesearch".to_string())); - } - if config_providers.calculator { - set.insert(ProviderType::Plugin("calc".to_string())); - } - if config_providers.websearch { - set.insert(ProviderType::Plugin("websearch".to_string())); - } - // Default to apps if nothing enabled - if set.is_empty() { - set.insert(ProviderType::Application); - } - set - }; - - let filter = Self { - enabled, - active_prefix: None, - }; - - #[cfg(feature = "dev-logging")] - debug!("[Filter] Created with enabled providers: {:?}", filter.enabled); - - filter - } - - /// Default filter: apps only - #[allow(dead_code)] - pub fn apps_only() -> Self { - Self { - enabled: HashSet::from([ProviderType::Application]), - active_prefix: None, - } - } - - /// Toggle a provider on/off - pub fn toggle(&mut self, provider: ProviderType) { - if self.enabled.contains(&provider) { - self.enabled.remove(&provider); - // Ensure at least one provider is always enabled - if self.enabled.is_empty() { - self.enabled.insert(ProviderType::Application); - } - #[cfg(feature = "dev-logging")] - debug!("[Filter] Toggled OFF {:?}, enabled: {:?}", provider, self.enabled); - } else { - #[cfg(feature = "dev-logging")] - let provider_debug = format!("{:?}", provider); - self.enabled.insert(provider); - #[cfg(feature = "dev-logging")] - debug!("[Filter] Toggled ON {}, enabled: {:?}", provider_debug, self.enabled); - } - } - - /// Enable a specific provider - pub fn enable(&mut self, provider: ProviderType) { - self.enabled.insert(provider); - } - - /// Disable a specific provider (ensures at least one remains) - pub fn disable(&mut self, provider: ProviderType) { - self.enabled.remove(&provider); - if self.enabled.is_empty() { - self.enabled.insert(ProviderType::Application); - } - } - - /// Set to single provider mode - pub fn set_single_mode(&mut self, provider: ProviderType) { - self.enabled.clear(); - self.enabled.insert(provider); - } - - /// Set prefix mode (from :app, :cmd, etc.) - pub fn set_prefix(&mut self, prefix: Option) { - #[cfg(feature = "dev-logging")] - if self.active_prefix != prefix { - debug!("[Filter] Prefix changed: {:?} -> {:?}", self.active_prefix, prefix); - } - self.active_prefix = prefix; - } - - /// Check if a provider should be searched - pub fn is_active(&self, provider: ProviderType) -> bool { - if let Some(ref prefix) = self.active_prefix { - &provider == prefix - } else { - self.enabled.contains(&provider) - } - } - - /// Check if provider is in enabled set (ignoring prefix) - pub fn is_enabled(&self, provider: ProviderType) -> bool { - self.enabled.contains(&provider) - } - - /// Get current active prefix if any - #[allow(dead_code)] - pub fn active_prefix(&self) -> Option { - self.active_prefix.clone() - } - - /// Parse query for prefix syntax - /// Prefixes map to Plugin(type_id) for plugin providers - pub fn parse_query(query: &str) -> ParsedQuery { - let trimmed = query.trim_start(); - - // Check for tag filter pattern: ":tag:XXX query" or ":tag:XXX" - if let Some(rest) = trimmed.strip_prefix(":tag:") { - // Find the end of the tag (space or end of string) - if let Some(space_idx) = rest.find(' ') { - let tag = rest[..space_idx].to_lowercase(); - let query_part = rest[space_idx + 1..].to_string(); - #[cfg(feature = "dev-logging")] - debug!("[Filter] parse_query({:?}) -> tag={:?}, query={:?}", query, tag, query_part); - return ParsedQuery { - prefix: None, - tag_filter: Some(tag), - query: query_part, - }; - } else { - // Just the tag, no query yet - let tag = rest.to_lowercase(); - return ParsedQuery { - prefix: None, - tag_filter: Some(tag), - query: String::new(), - }; - } - } - - // Core provider prefixes - let core_prefixes: &[(&str, ProviderType)] = &[ - (":app ", ProviderType::Application), - (":apps ", ProviderType::Application), - (":cmd ", ProviderType::Command), - (":command ", ProviderType::Command), - ]; - - // Plugin provider prefixes - mapped to Plugin(type_id) - let plugin_prefixes: &[(&str, &str)] = &[ - (":bm ", "bookmarks"), - (":bookmark ", "bookmarks"), - (":bookmarks ", "bookmarks"), - (":calc ", "calc"), - (":calculator ", "calc"), - (":clip ", "clipboard"), - (":clipboard ", "clipboard"), - (":emoji ", "emoji"), - (":emojis ", "emoji"), - (":file ", "filesearch"), - (":files ", "filesearch"), - (":find ", "filesearch"), - (":script ", "scripts"), - (":scripts ", "scripts"), - (":ssh ", "ssh"), - (":sys ", "system"), - (":system ", "system"), - (":power ", "system"), - (":uuctl ", "uuctl"), - (":systemd ", "uuctl"), - (":web ", "websearch"), - (":search ", "websearch"), - ]; - - // Check core prefixes - for (prefix_str, provider) in core_prefixes { - if let Some(rest) = trimmed.strip_prefix(prefix_str) { - #[cfg(feature = "dev-logging")] - debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, provider, rest); - return ParsedQuery { - prefix: Some(provider.clone()), - tag_filter: None, - query: rest.to_string(), - }; - } - } - - // Check plugin prefixes - for (prefix_str, type_id) in plugin_prefixes { - if let Some(rest) = trimmed.strip_prefix(prefix_str) { - let provider = ProviderType::Plugin(type_id.to_string()); - #[cfg(feature = "dev-logging")] - debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, provider, rest); - return ParsedQuery { - prefix: Some(provider), - tag_filter: None, - query: rest.to_string(), - }; - } - } - - // Handle partial prefixes (still typing) - let partial_core: &[(&str, ProviderType)] = &[ - (":app", ProviderType::Application), - (":apps", ProviderType::Application), - (":cmd", ProviderType::Command), - (":command", ProviderType::Command), - ]; - - let partial_plugin: &[(&str, &str)] = &[ - (":bm", "bookmarks"), - (":bookmark", "bookmarks"), - (":bookmarks", "bookmarks"), - (":calc", "calc"), - (":calculator", "calc"), - (":clip", "clipboard"), - (":clipboard", "clipboard"), - (":emoji", "emoji"), - (":emojis", "emoji"), - (":file", "filesearch"), - (":files", "filesearch"), - (":find", "filesearch"), - (":script", "scripts"), - (":scripts", "scripts"), - (":ssh", "ssh"), - (":sys", "system"), - (":system", "system"), - (":power", "system"), - (":uuctl", "uuctl"), - (":systemd", "uuctl"), - (":web", "websearch"), - (":search", "websearch"), - ]; - - for (prefix_str, provider) in partial_core { - if trimmed == *prefix_str { - #[cfg(feature = "dev-logging")] - debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider); - return ParsedQuery { - prefix: Some(provider.clone()), - tag_filter: None, - query: String::new(), - }; - } - } - - for (prefix_str, type_id) in partial_plugin { - if trimmed == *prefix_str { - let provider = ProviderType::Plugin(type_id.to_string()); - #[cfg(feature = "dev-logging")] - debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider); - return ParsedQuery { - prefix: Some(provider), - tag_filter: None, - query: String::new(), - }; - } - } - - let result = ParsedQuery { - prefix: None, - tag_filter: None, - query: query.to_string(), - }; - - #[cfg(feature = "dev-logging")] - debug!("[Filter] parse_query({:?}) -> prefix={:?}, tag={:?}, query={:?}", query, result.prefix, result.tag_filter, result.query); - - result - } - - /// Get enabled providers for UI display (sorted) - pub fn enabled_providers(&self) -> Vec { - let mut providers: Vec<_> = self.enabled.iter().cloned().collect(); - providers.sort_by_key(|p| match p { - ProviderType::Application => 0, - ProviderType::Command => 1, - ProviderType::Dmenu => 2, - ProviderType::Plugin(_) => 100, // Plugin providers sort after core - }); - providers - } - - /// Get display name for current mode - pub fn mode_display_name(&self) -> &'static str { - if let Some(ref prefix) = self.active_prefix { - return match prefix { - ProviderType::Application => "Apps", - ProviderType::Command => "Commands", - ProviderType::Dmenu => "dmenu", - ProviderType::Plugin(_) => "Plugin", - }; - } - - let enabled: Vec<_> = self.enabled_providers(); - if enabled.len() == 1 { - match &enabled[0] { - ProviderType::Application => "Apps", - ProviderType::Command => "Commands", - ProviderType::Dmenu => "dmenu", - ProviderType::Plugin(_) => "Plugin", - } - } else { - "All" - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_query_with_prefix() { - let result = ProviderFilter::parse_query(":app firefox"); - assert_eq!(result.prefix, Some(ProviderType::Application)); - assert_eq!(result.query, "firefox"); - } - - #[test] - fn test_parse_query_without_prefix() { - let result = ProviderFilter::parse_query("firefox"); - assert_eq!(result.prefix, None); - assert_eq!(result.query, "firefox"); - } - - #[test] - fn test_parse_query_partial_prefix() { - let result = ProviderFilter::parse_query(":cmd"); - assert_eq!(result.prefix, Some(ProviderType::Command)); - assert_eq!(result.query, ""); - } - - #[test] - fn test_parse_query_plugin_prefix() { - let result = ProviderFilter::parse_query(":calc 5+3"); - assert_eq!(result.prefix, Some(ProviderType::Plugin("calc".to_string()))); - assert_eq!(result.query, "5+3"); - } - - #[test] - fn test_toggle_ensures_one_enabled() { - let mut filter = ProviderFilter::apps_only(); - filter.toggle(ProviderType::Application); - // Should still have apps enabled as fallback - assert!(filter.is_enabled(ProviderType::Application)); - } -} diff --git a/crates/owlry/src/main.rs b/crates/owlry/src/main.rs index 5ac83a0..ec99458 100644 --- a/crates/owlry/src/main.rs +++ b/crates/owlry/src/main.rs @@ -1,11 +1,6 @@ mod app; mod cli; -mod config; -mod data; -mod filter; -mod notify; -mod paths; -mod plugins; +mod plugin_commands; mod providers; mod theme; mod ui; @@ -25,7 +20,7 @@ fn main() { // CLI commands don't need full logging match command { Command::Plugin(plugin_cmd) => { - if let Err(e) = plugins::commands::execute(plugin_cmd.clone()) { + if let Err(e) = plugin_commands::execute(plugin_cmd.clone()) { eprintln!("Error: {}", e); std::process::exit(1); } diff --git a/crates/owlry/src/notify.rs b/crates/owlry/src/notify.rs deleted file mode 100644 index dbfc9ac..0000000 --- a/crates/owlry/src/notify.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! Desktop notification system -//! -//! Provides system notifications for owlry and its plugins. -//! Uses the freedesktop notification specification via notify-rust. -//! -//! Note: Some convenience functions are provided for future use and -//! are currently unused by the core (plugins use the Host API instead). - -#![allow(dead_code)] - -use notify_rust::{Notification, Urgency}; - -/// Notification urgency level -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum NotifyUrgency { - /// Low priority notification - Low, - /// Normal priority notification (default) - #[default] - Normal, - /// Critical/urgent notification - Critical, -} - -impl From for Urgency { - fn from(urgency: NotifyUrgency) -> Self { - match urgency { - NotifyUrgency::Low => Urgency::Low, - NotifyUrgency::Normal => Urgency::Normal, - NotifyUrgency::Critical => Urgency::Critical, - } - } -} - -/// Send a simple notification -pub fn notify(summary: &str, body: &str) { - notify_with_options(summary, body, None, NotifyUrgency::Normal); -} - -/// Send a notification with an icon -pub fn notify_with_icon(summary: &str, body: &str, icon: &str) { - notify_with_options(summary, body, Some(icon), NotifyUrgency::Normal); -} - -/// Send a notification with full options -pub fn notify_with_options(summary: &str, body: &str, icon: Option<&str>, urgency: NotifyUrgency) { - let mut notification = Notification::new(); - notification - .appname("Owlry") - .summary(summary) - .body(body) - .urgency(urgency.into()); - - if let Some(icon_name) = icon { - notification.icon(icon_name); - } - - if let Err(e) = notification.show() { - log::warn!("Failed to show notification: {}", e); - } -} - -/// Send a notification with a timeout -pub fn notify_with_timeout(summary: &str, body: &str, icon: Option<&str>, timeout_ms: i32) { - let mut notification = Notification::new(); - notification - .appname("Owlry") - .summary(summary) - .body(body) - .timeout(timeout_ms); - - if let Some(icon_name) = icon { - notification.icon(icon_name); - } - - if let Err(e) = notification.show() { - log::warn!("Failed to show notification: {}", e); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_urgency_conversion() { - assert_eq!(Urgency::from(NotifyUrgency::Low), Urgency::Low); - assert_eq!(Urgency::from(NotifyUrgency::Normal), Urgency::Normal); - assert_eq!(Urgency::from(NotifyUrgency::Critical), Urgency::Critical); - } -} diff --git a/crates/owlry/src/paths.rs b/crates/owlry/src/paths.rs deleted file mode 100644 index a846063..0000000 --- a/crates/owlry/src/paths.rs +++ /dev/null @@ -1,203 +0,0 @@ -//! Centralized path handling following XDG Base Directory Specification. -//! -//! XDG directories used: -//! - `$XDG_CONFIG_HOME/owlry/` - User configuration (config.toml, themes/, style.css) -//! - `$XDG_DATA_HOME/owlry/` - User data (scripts/, frecency.json) -//! - `$XDG_CACHE_HOME/owlry/` - Cache files (future use) -//! -//! See: https://specifications.freedesktop.org/basedir-spec/latest/ - -use std::path::PathBuf; - -/// Application name used in XDG paths -const APP_NAME: &str = "owlry"; - -// ============================================================================= -// XDG Base Directories -// ============================================================================= - -/// Get XDG config home: `$XDG_CONFIG_HOME` or `~/.config` -pub fn config_home() -> Option { - dirs::config_dir() -} - -/// Get XDG data home: `$XDG_DATA_HOME` or `~/.local/share` -pub fn data_home() -> Option { - dirs::data_dir() -} - -/// Get XDG cache home: `$XDG_CACHE_HOME` or `~/.cache` -#[allow(dead_code)] -pub fn cache_home() -> Option { - dirs::cache_dir() -} - - -// ============================================================================= -// Owlry-specific directories -// ============================================================================= - -/// Owlry config directory: `$XDG_CONFIG_HOME/owlry/` -pub fn owlry_config_dir() -> Option { - config_home().map(|p| p.join(APP_NAME)) -} - -/// Owlry data directory: `$XDG_DATA_HOME/owlry/` -pub fn owlry_data_dir() -> Option { - data_home().map(|p| p.join(APP_NAME)) -} - -/// Owlry cache directory: `$XDG_CACHE_HOME/owlry/` -#[allow(dead_code)] -pub fn owlry_cache_dir() -> Option { - cache_home().map(|p| p.join(APP_NAME)) -} - -// ============================================================================= -// Config files -// ============================================================================= - -/// Main config file: `$XDG_CONFIG_HOME/owlry/config.toml` -pub fn config_file() -> Option { - owlry_config_dir().map(|p| p.join("config.toml")) -} - -/// Custom user stylesheet: `$XDG_CONFIG_HOME/owlry/style.css` -pub fn custom_style_file() -> Option { - owlry_config_dir().map(|p| p.join("style.css")) -} - -/// User themes directory: `$XDG_CONFIG_HOME/owlry/themes/` -pub fn themes_dir() -> Option { - owlry_config_dir().map(|p| p.join("themes")) -} - -/// Get path for a specific theme: `$XDG_CONFIG_HOME/owlry/themes/{name}.css` -pub fn theme_file(name: &str) -> Option { - themes_dir().map(|p| p.join(format!("{}.css", name))) -} - -// ============================================================================= -// Data files -// ============================================================================= - -/// User plugins directory: `$XDG_CONFIG_HOME/owlry/plugins/` -/// -/// Plugins are stored in config because they contain user-installed code -/// that the user explicitly chose to add (similar to themes). -pub fn plugins_dir() -> Option { - owlry_config_dir().map(|p| p.join("plugins")) -} - -/// Frecency data file: `$XDG_DATA_HOME/owlry/frecency.json` -pub fn frecency_file() -> Option { - owlry_data_dir().map(|p| p.join("frecency.json")) -} - -// ============================================================================= -// System directories -// ============================================================================= - -/// System data directories for applications (XDG_DATA_DIRS) -/// -/// Follows the XDG Base Directory Specification: -/// - $XDG_DATA_HOME/applications (defaults to ~/.local/share/applications) -/// - $XDG_DATA_DIRS/*/applications (defaults to /usr/local/share:/usr/share) -/// - Additional Flatpak and Snap directories -pub fn system_data_dirs() -> Vec { - let mut dirs = Vec::new(); - let mut seen = std::collections::HashSet::new(); - - // Helper to add unique directories - let mut add_dir = |path: PathBuf| { - if seen.insert(path.clone()) { - dirs.push(path); - } - }; - - // 1. User data directory first (highest priority) - if let Some(data) = data_home() { - add_dir(data.join("applications")); - } - - // 2. XDG_DATA_DIRS - parse the environment variable - // Default per spec: /usr/local/share:/usr/share - let xdg_data_dirs = std::env::var("XDG_DATA_DIRS") - .unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string()); - - for dir in xdg_data_dirs.split(':') { - if !dir.is_empty() { - add_dir(PathBuf::from(dir).join("applications")); - } - } - - // 3. Always include standard system directories as fallback - // Some environments set XDG_DATA_DIRS without including these - add_dir(PathBuf::from("/usr/share/applications")); - add_dir(PathBuf::from("/usr/local/share/applications")); - - // 4. Flatpak directories (user and system) - if let Some(data) = data_home() { - add_dir(data.join("flatpak/exports/share/applications")); - } - add_dir(PathBuf::from("/var/lib/flatpak/exports/share/applications")); - - // 5. Snap directories - add_dir(PathBuf::from("/var/lib/snapd/desktop/applications")); - - // 6. Nix directories (common on NixOS) - if let Some(home) = dirs::home_dir() { - add_dir(home.join(".nix-profile/share/applications")); - } - add_dir(PathBuf::from("/run/current-system/sw/share/applications")); - - dirs -} - -// ============================================================================= -// Helper functions -// ============================================================================= - -/// Ensure parent directory of a file exists -pub fn ensure_parent_dir(path: &std::path::Path) -> std::io::Result<()> { - if let Some(parent) = path.parent() - && !parent.exists() { - std::fs::create_dir_all(parent)?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_paths_are_consistent() { - // All owlry paths should be under XDG directories - if let (Some(config), Some(data)) = (owlry_config_dir(), owlry_data_dir()) { - assert!(config.ends_with("owlry")); - assert!(data.ends_with("owlry")); - } - } - - #[test] - fn test_config_file_path() { - if let Some(path) = config_file() { - assert!(path.ends_with("config.toml")); - assert!(path.to_string_lossy().contains("owlry")); - } - } - - #[test] - fn test_frecency_in_data_dir() { - if let Some(path) = frecency_file() { - assert!(path.ends_with("frecency.json")); - // Should be in data dir, not config dir - let path_str = path.to_string_lossy(); - assert!( - path_str.contains(".local/share") || path_str.contains("XDG_DATA_HOME"), - "frecency should be in data directory" - ); - } - } -} diff --git a/crates/owlry/src/plugins/commands.rs b/crates/owlry/src/plugin_commands.rs similarity index 98% rename from crates/owlry/src/plugins/commands.rs rename to crates/owlry/src/plugin_commands.rs index 4117a71..731f47c 100644 --- a/crates/owlry/src/plugins/commands.rs +++ b/crates/owlry/src/plugin_commands.rs @@ -7,11 +7,11 @@ use std::io::{self, Write}; use std::path::{Path, PathBuf}; use crate::cli::{PluginCommand as CliPluginCommand, PluginRuntime}; -use crate::config::Config; -use crate::paths; -use crate::plugins::manifest::{discover_plugins, PluginManifest}; -use crate::plugins::registry::{self, RegistryClient}; -use crate::plugins::runtime_loader::{lua_runtime_available, rune_runtime_available}; +use owlry_core::config::Config; +use owlry_core::paths; +use owlry_core::plugins::manifest::{discover_plugins, PluginManifest}; +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>; @@ -932,7 +932,7 @@ fn cmd_validate(path: Option<&str>) -> CommandResult { /// Show available script runtimes fn cmd_runtimes() -> CommandResult { - use crate::plugins::runtime_loader::SYSTEM_RUNTIMES_DIR; + use owlry_core::plugins::runtime_loader::SYSTEM_RUNTIMES_DIR; println!("Script Runtimes:\n"); @@ -1024,7 +1024,7 @@ fn execute_plugin_command( command: &str, args: &[String], ) -> CommandResult { - use crate::plugins::runtime_loader::{LoadedRuntime, SYSTEM_RUNTIMES_DIR}; + use owlry_core::plugins::runtime_loader::{LoadedRuntime, SYSTEM_RUNTIMES_DIR}; let runtime = detect_runtime(manifest); diff --git a/crates/owlry/src/plugins/api/action.rs b/crates/owlry/src/plugins/api/action.rs deleted file mode 100644 index 985f574..0000000 --- a/crates/owlry/src/plugins/api/action.rs +++ /dev/null @@ -1,322 +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/src/plugins/api/cache.rs b/crates/owlry/src/plugins/api/cache.rs deleted file mode 100644 index 448b066..0000000 --- a/crates/owlry/src/plugins/api/cache.rs +++ /dev/null @@ -1,299 +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/src/plugins/api/hook.rs b/crates/owlry/src/plugins/api/hook.rs deleted file mode 100644 index b660964..0000000 --- a/crates/owlry/src/plugins/api/hook.rs +++ /dev/null @@ -1,410 +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/src/plugins/api/http.rs b/crates/owlry/src/plugins/api/http.rs deleted file mode 100644 index 49b7490..0000000 --- a/crates/owlry/src/plugins/api/http.rs +++ /dev/null @@ -1,345 +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/src/plugins/api/math.rs b/crates/owlry/src/plugins/api/math.rs deleted file mode 100644 index 54a961c..0000000 --- a/crates/owlry/src/plugins/api/math.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! Math calculation API for Lua plugins -//! -//! Provides safe math expression evaluation: -//! - `owlry.math.calculate(expression)` - Evaluate a math expression - -use mlua::{Lua, Result as LuaResult, 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 meval::eval_str(&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.to_string()))) - } - } - })?, - )?; - - // 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| { - meval::eval_str(&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/src/plugins/api/mod.rs b/crates/owlry/src/plugins/api/mod.rs deleted file mode 100644 index 10fa1ef..0000000 --- a/crates/owlry/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/src/plugins/api/process.rs b/crates/owlry/src/plugins/api/process.rs deleted file mode 100644 index b8b5204..0000000 --- a/crates/owlry/src/plugins/api/process.rs +++ /dev/null @@ -1,207 +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/src/plugins/api/provider.rs b/crates/owlry/src/plugins/api/provider.rs deleted file mode 100644 index 124c240..0000000 --- a/crates/owlry/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/src/plugins/api/theme.rs b/crates/owlry/src/plugins/api/theme.rs deleted file mode 100644 index e500222..0000000 --- a/crates/owlry/src/plugins/api/theme.rs +++ /dev/null @@ -1,275 +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/src/plugins/api/utils.rs b/crates/owlry/src/plugins/api/utils.rs deleted file mode 100644 index 2f6df20..0000000 --- a/crates/owlry/src/plugins/api/utils.rs +++ /dev/null @@ -1,567 +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/src/plugins/error.rs b/crates/owlry/src/plugins/error.rs deleted file mode 100644 index af6ce43..0000000 --- a/crates/owlry/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/src/plugins/loader.rs b/crates/owlry/src/plugins/loader.rs deleted file mode 100644 index 4a6f0ee..0000000 --- a/crates/owlry/src/plugins/loader.rs +++ /dev/null @@ -1,205 +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::{create_lua_runtime, load_file, SandboxConfig}; - -/// 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::*; - use super::super::manifest::{check_compatibility, discover_plugins}; - 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/src/plugins/manifest.rs b/crates/owlry/src/plugins/manifest.rs deleted file mode 100644 index 929d6cf..0000000 --- a/crates/owlry/src/plugins/manifest.rs +++ /dev/null @@ -1,318 +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, - #[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")] - pub entry: String, -} - -fn default_owlry_version() -> String { - ">=0.1.0".to_string() -} - -fn default_entry() -> String { - "init.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, - /// 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, "init.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_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/src/plugins/mod.rs b/crates/owlry/src/plugins/mod.rs deleted file mode 100644 index 403b6c0..0000000 --- a/crates/owlry/src/plugins/mod.rs +++ /dev/null @@ -1,337 +0,0 @@ -//! Owlry Plugin System -//! -//! This module provides plugin support for extending owlry's functionality. -//! Plugins can register providers, actions, themes, and hooks. -//! -//! # Plugin Types -//! -//! - **Native plugins** (.so): Pre-compiled Rust plugins loaded from `/usr/lib/owlry/plugins/` -//! - **Lua plugins**: Script-based plugins from `~/.config/owlry/plugins/` (requires `lua` feature) -//! -//! # Plugin Structure (Lua) -//! -//! Each Lua plugin lives in its own directory under `~/.config/owlry/plugins/`: -//! -//! ```text -//! ~/.config/owlry/plugins/ -//! my-plugin/ -//! plugin.toml # Plugin manifest -//! init.lua # Entry point -//! lib/ # Optional modules -//! ``` - -// Always available -pub mod commands; -pub mod error; -pub mod manifest; -pub mod native_loader; -pub mod registry; -pub mod runtime_loader; - -// 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::{check_compatibility, discover_plugins, PluginManifest}; - -// ============================================================================ -// Lua Plugin Manager (only available with lua feature) -// ============================================================================ - -#[cfg(feature = "lua")] -mod lua_manager { - use super::*; - use std::cell::RefCell; - use std::collections::HashMap; - use std::path::PathBuf; - use std::rc::Rc; - - use manifest::{discover_plugins, check_compatibility}; - - /// 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 (Rc> allows sharing with LuaProviders) - 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, Rc::new(RefCell::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.borrow_mut(); - 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 (returns Rc for shared ownership) - #[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.borrow().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.borrow().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.borrow_mut(); - - 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.borrow_mut().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.borrow().manifest.provides.providers.contains(&provider_name.to_string())) - .map(|p| p.borrow().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.borrow().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.borrow().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.borrow().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/src/plugins/native_loader.rs b/crates/owlry/src/plugins/native_loader.rs deleted file mode 100644 index 05d539d..0000000 --- a/crates/owlry/src/plugins/native_loader.rs +++ /dev/null @@ -1,391 +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}; - -use libloading::Library; -use log::{debug, error, info, warn}; -use owlry_plugin_api::{ - HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo, ProviderKind, - RStr, API_VERSION, -}; - -use crate::notify; - -// ============================================================================ -// 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, -}; - -/// 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/src/plugins/registry.rs b/crates/owlry/src/plugins/registry.rs deleted file mode 100644 index 42c6798..0000000 --- a/crates/owlry/src/plugins/registry.rs +++ /dev/null @@ -1,293 +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/src/plugins/runtime.rs b/crates/owlry/src/plugins/runtime.rs deleted file mode 100644 index da98dbe..0000000 --- a/crates/owlry/src/plugins/runtime.rs +++ /dev/null @@ -1,153 +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/src/plugins/runtime_loader.rs b/crates/owlry/src/plugins/runtime_loader.rs deleted file mode 100644 index de62fcd..0000000 --- a/crates/owlry/src/plugins/runtime_loader.rs +++ /dev/null @@ -1,286 +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. - -#![allow(dead_code)] - -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use libloading::{Library, Symbol}; -use owlry_plugin_api::{PluginItem, RStr, RString, RVec}; - -use super::error::{PluginError, PluginResult}; -use crate::providers::{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, -} - -// Type alias for backwards compatibility -pub type LuaProviderInfo = ScriptProviderInfo; - -/// Handle to runtime-managed state -#[repr(transparent)] -#[derive(Clone, Copy)] -pub struct RuntimeHandle(pub *mut ()); - -/// 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<'_>) -> 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 - _library: Arc, - /// Runtime vtable - vtable: &'static ScriptRuntimeVTable, - /// Runtime handle (state) - handle: RuntimeHandle, - /// Provider information - providers: Vec, -} - -impl LoadedRuntime { - /// Load the Lua runtime from the system directory - pub fn load_lua(plugins_dir: &Path) -> PluginResult { - Self::load_from_path( - "Lua", - &PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so"), - b"owlry_lua_runtime_vtable", - plugins_dir, - ) - } - - /// Load a runtime from a specific path - fn load_from_path( - name: &'static str, - library_path: &Path, - vtable_symbol: &[u8], - plugins_dir: &Path, - ) -> 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 handle = (vtable.init)(RStr::from_str(&plugins_dir_str)); - - // Get provider information - let providers_rvec = (vtable.providers)(handle); - let providers: Vec = providers_rvec.into_iter().collect(); - - log::info!( - "Loaded {} runtime with {} provider(s)", - name, - providers.len() - ); - - Ok(Self { - name, - _library: 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, - self.handle, - info.clone(), - ); - Box::new(provider) as Box - }) - .collect() - } -} - -impl Drop for LoadedRuntime { - fn drop(&mut self) { - (self.vtable.drop)(self.handle); - } -} - -/// 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, - handle: RuntimeHandle, - info: ScriptProviderInfo, - items: Vec, -} - -impl RuntimeProvider { - fn new( - runtime_name: &'static str, - vtable: &'static ScriptRuntimeVTable, - handle: RuntimeHandle, - 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(), - } - } -} - -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 = (self.vtable.refresh)(self.handle, 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 - } -} - -// RuntimeProvider needs to be Send for the Provider trait -unsafe impl Send for RuntimeProvider {} - -/// 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) -> PluginResult { - Self::load_from_path( - "Rune", - &PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so"), - b"owlry_rune_runtime_vtable", - plugins_dir, - ) - } -} - -#[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/src/providers/application.rs b/crates/owlry/src/providers/application.rs deleted file mode 100644 index 3236e64..0000000 --- a/crates/owlry/src/providers/application.rs +++ /dev/null @@ -1,266 +0,0 @@ -use super::{LaunchItem, Provider, ProviderType}; -use crate::paths; -use freedesktop_desktop_entry::{DesktopEntry, Iter}; -use log::{debug, warn}; - -/// Clean desktop file field codes from command string. -/// Removes %f, %F, %u, %U, %d, %D, %n, %N, %i, %c, %k, %v, %m field codes -/// while preserving quoted arguments and %% (literal percent). -/// See: https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html -fn clean_desktop_exec_field(cmd: &str) -> String { - let mut result = String::with_capacity(cmd.len()); - let mut chars = cmd.chars().peekable(); - let mut in_single_quote = false; - let mut in_double_quote = false; - - while let Some(c) = chars.next() { - match c { - '\'' if !in_double_quote => { - in_single_quote = !in_single_quote; - result.push(c); - } - '"' if !in_single_quote => { - in_double_quote = !in_double_quote; - result.push(c); - } - '%' if !in_single_quote => { - // Check the next character for field code - if let Some(&next) = chars.peek() { - match next { - // Standard field codes to remove (with following space if present) - 'f' | 'F' | 'u' | 'U' | 'd' | 'D' | 'n' | 'N' | 'i' | 'c' | 'k' | 'v' - | 'm' => { - chars.next(); // consume the field code letter - // Skip trailing whitespace after the field code - while chars.peek() == Some(&' ') { - chars.next(); - } - } - // %% is escaped percent, output single % - '%' => { - chars.next(); - result.push('%'); - } - // Unknown % sequence, keep as-is - _ => { - result.push('%'); - } - } - } else { - // % at end of string, keep it - result.push('%'); - } - } - _ => { - result.push(c); - } - } - } - - // Clean up any double spaces that may have resulted from removing field codes - let mut cleaned = result.trim().to_string(); - while cleaned.contains(" ") { - cleaned = cleaned.replace(" ", " "); - } - - cleaned -} - -pub struct ApplicationProvider { - items: Vec, -} - -impl ApplicationProvider { - pub fn new() -> Self { - Self { items: Vec::new() } - } - - fn get_application_dirs() -> Vec { - paths::system_data_dirs() - } -} - -impl Provider for ApplicationProvider { - fn name(&self) -> &str { - "Applications" - } - - fn provider_type(&self) -> ProviderType { - ProviderType::Application - } - - fn refresh(&mut self) { - self.items.clear(); - - let dirs = Self::get_application_dirs(); - debug!("Scanning application directories: {:?}", dirs); - - // Empty locale list for default locale - let locales: &[&str] = &[]; - - // Get current desktop environment(s) for OnlyShowIn/NotShowIn filtering - // XDG_CURRENT_DESKTOP can be colon-separated (e.g., "ubuntu:GNOME") - let current_desktops: Vec = std::env::var("XDG_CURRENT_DESKTOP") - .unwrap_or_default() - .split(':') - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(); - - for path in Iter::new(dirs.into_iter()) { - let content = match std::fs::read_to_string(&path) { - Ok(c) => c, - Err(e) => { - warn!("Failed to read {:?}: {}", path, e); - continue; - } - }; - - let desktop_entry = match DesktopEntry::from_str(&path, &content, Some(locales)) { - Ok(e) => e, - Err(e) => { - warn!("Failed to parse {:?}: {}", path, e); - continue; - } - }; - - // Skip entries marked as hidden or no-display - if desktop_entry.no_display() || desktop_entry.hidden() { - continue; - } - - // Only include Application type entries - if desktop_entry.type_() != Some("Application") { - continue; - } - - // Apply OnlyShowIn/NotShowIn filters only if we know the current desktop - // If XDG_CURRENT_DESKTOP is not set, show all apps (don't filter) - if !current_desktops.is_empty() { - // OnlyShowIn: if set, current desktop must be in the list - if desktop_entry.only_show_in().is_some_and(|only| { - !current_desktops.iter().any(|de| only.contains(&de.as_str())) - }) { - continue; - } - - // NotShowIn: if current desktop is in the list, skip - if desktop_entry.not_show_in().is_some_and(|not| { - current_desktops.iter().any(|de| not.contains(&de.as_str())) - }) { - continue; - } - } - - let name = match desktop_entry.name(locales) { - Some(n) => n.to_string(), - None => continue, - }; - - let run_cmd = match desktop_entry.exec() { - Some(e) => clean_desktop_exec_field(e), - None => continue, - }; - - // Extract categories and keywords as tags (lowercase for consistency) - let mut tags: Vec = desktop_entry - .categories() - .map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect()) - .unwrap_or_default(); - - // Add keywords for searchability (e.g., Nautilus has Name=Files but Keywords contains "nautilus") - if let Some(keywords) = desktop_entry.keywords(locales) { - tags.extend(keywords.into_iter().map(|s| s.to_lowercase())); - } - - let item = LaunchItem { - id: path.to_string_lossy().to_string(), - name, - description: desktop_entry.comment(locales).map(|s| s.to_string()), - icon: desktop_entry.icon().map(|s| s.to_string()), - provider: ProviderType::Application, - command: run_cmd, - terminal: desktop_entry.terminal(), - tags, - }; - - self.items.push(item); - } - - debug!("Found {} applications", self.items.len()); - - #[cfg(feature = "dev-logging")] - debug!( - "XDG_CURRENT_DESKTOP={:?}, scanned dirs count={}", - current_desktops, - Self::get_application_dirs().len() - ); - - // Sort alphabetically by name - self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); - } - - fn items(&self) -> &[LaunchItem] { - &self.items - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_clean_desktop_exec_simple() { - assert_eq!(clean_desktop_exec_field("firefox"), "firefox"); - assert_eq!(clean_desktop_exec_field("firefox %u"), "firefox"); - assert_eq!(clean_desktop_exec_field("code %F"), "code"); - } - - #[test] - fn test_clean_desktop_exec_multiple_placeholders() { - assert_eq!(clean_desktop_exec_field("app %f %u %U"), "app"); - assert_eq!(clean_desktop_exec_field("app --flag %u --other"), "app --flag --other"); - } - - #[test] - fn test_clean_desktop_exec_preserves_quotes() { - // Double quotes preserve spacing but field codes are still processed - assert_eq!( - clean_desktop_exec_field(r#"bash -c "echo hello""#), - r#"bash -c "echo hello""# - ); - // Field codes in double quotes are stripped (per FreeDesktop spec: undefined behavior, - // but practical implementations strip them) - assert_eq!( - clean_desktop_exec_field(r#"bash -c "test %u value""#), - r#"bash -c "test value""# - ); - } - - #[test] - fn test_clean_desktop_exec_escaped_percent() { - assert_eq!(clean_desktop_exec_field("echo 100%%"), "echo 100%"); - } - - #[test] - fn test_clean_desktop_exec_single_quotes() { - assert_eq!( - clean_desktop_exec_field("bash -c 'echo %u'"), - "bash -c 'echo %u'" - ); - } - - #[test] - fn test_clean_desktop_exec_preserves_env() { - // env VAR=value pattern should be preserved - assert_eq!( - clean_desktop_exec_field("env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity %F"), - "env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity" - ); - // Multiple env vars - assert_eq!( - clean_desktop_exec_field("env FOO=bar BAZ=qux myapp %u"), - "env FOO=bar BAZ=qux myapp" - ); - } -} diff --git a/crates/owlry/src/providers/command.rs b/crates/owlry/src/providers/command.rs deleted file mode 100644 index 0df024f..0000000 --- a/crates/owlry/src/providers/command.rs +++ /dev/null @@ -1,106 +0,0 @@ -use super::{LaunchItem, Provider, ProviderType}; -use log::debug; -use std::collections::HashSet; -use std::os::unix::fs::PermissionsExt; -use std::path::PathBuf; - -pub struct CommandProvider { - items: Vec, -} - -impl CommandProvider { - pub fn new() -> Self { - Self { items: Vec::new() } - } - - fn get_path_dirs() -> Vec { - std::env::var("PATH") - .unwrap_or_default() - .split(':') - .map(PathBuf::from) - .filter(|p| p.exists()) - .collect() - } - - fn is_executable(path: &std::path::Path) -> bool { - if let Ok(metadata) = path.metadata() { - let permissions = metadata.permissions(); - permissions.mode() & 0o111 != 0 - } else { - false - } - } -} - -impl Provider for CommandProvider { - fn name(&self) -> &str { - "Commands" - } - - fn provider_type(&self) -> ProviderType { - ProviderType::Command - } - - fn refresh(&mut self) { - self.items.clear(); - - let dirs = Self::get_path_dirs(); - let mut seen_names: HashSet = HashSet::new(); - - debug!("Scanning PATH directories for commands"); - - for dir in dirs { - let entries = match std::fs::read_dir(&dir) { - Ok(e) => e, - Err(_) => continue, - }; - - for entry in entries.filter_map(Result::ok) { - let path = entry.path(); - - // Skip directories and non-executable files - if path.is_dir() || !Self::is_executable(&path) { - continue; - } - - let name = match path.file_name() { - Some(n) => n.to_string_lossy().to_string(), - None => continue, - }; - - // Skip duplicates (first one in PATH wins) - if seen_names.contains(&name) { - continue; - } - seen_names.insert(name.clone()); - - // Skip hidden files - if name.starts_with('.') { - continue; - } - - let item = LaunchItem { - id: path.to_string_lossy().to_string(), - name: name.clone(), - description: Some(format!("Run {}", path.display())), - icon: Some("utilities-terminal".to_string()), - provider: ProviderType::Command, - command: name, - terminal: false, - tags: Vec::new(), - }; - - self.items.push(item); - } - } - - debug!("Found {} commands in PATH", self.items.len()); - - // Sort alphabetically - self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); - } - - fn items(&self) -> &[LaunchItem] { - &self.items - } -} diff --git a/crates/owlry/src/providers/dmenu.rs b/crates/owlry/src/providers/dmenu.rs index 84a1022..1a6b8b3 100644 --- a/crates/owlry/src/providers/dmenu.rs +++ b/crates/owlry/src/providers/dmenu.rs @@ -1,4 +1,4 @@ -use super::{LaunchItem, Provider, ProviderType}; +use owlry_core::providers::{LaunchItem, Provider, ProviderType}; use log::debug; use std::io::{self, BufRead}; diff --git a/crates/owlry/src/providers/lua_provider.rs b/crates/owlry/src/providers/lua_provider.rs deleted file mode 100644 index d624846..0000000 --- a/crates/owlry/src/providers/lua_provider.rs +++ /dev/null @@ -1,142 +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::cell::RefCell; -use std::rc::Rc; - -use crate::plugins::{LoadedPlugin, PluginItem, ProviderRegistration}; - -use super::{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) - plugin: Rc>, - /// Cached items from last refresh - items: Vec, -} - -impl LuaProvider { - /// Create a new LuaProvider - pub fn new(registration: ProviderRegistration, plugin: Rc>) -> Self { - Self { - registration, - plugin, - items: Vec::new(), - } - } - - /// Convert a PluginItem to a LaunchItem - fn convert_item(&self, item: PluginItem) -> LaunchItem { - 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, - } - } -} - -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.borrow(); - 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 needs to be Send for the Provider trait -// Since we're using Rc>, we need to be careful about thread safety -// For now, owlry is single-threaded, so this is safe -unsafe impl Send for LuaProvider {} - -/// Create LuaProviders from all registered providers in a plugin -pub fn create_providers_from_plugin( - plugin: Rc>, -) -> Vec> { - let registrations = { - let p = plugin.borrow(); - 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/src/providers/mod.rs b/crates/owlry/src/providers/mod.rs index bc1b9ee..bbb7ad5 100644 --- a/crates/owlry/src/providers/mod.rs +++ b/crates/owlry/src/providers/mod.rs @@ -1,616 +1,2 @@ -// Core providers (no plugin equivalents) -mod application; -mod command; -mod dmenu; - -// 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; +pub mod dmenu; pub use dmenu::DmenuProvider; - -// Re-export native provider for plugin loading -pub use native_provider::NativeProvider; - -use fuzzy_matcher::FuzzyMatcher; -use fuzzy_matcher::skim::SkimMatcherV2; -use log::info; - -#[cfg(feature = "dev-logging")] -use log::debug; - -use crate::data::FrecencyStore; - -/// 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, -} - -/// Provider type identifier for filtering and badge display -/// -/// Core types are built-in providers. All native plugins use Plugin(type_id). -/// This keeps the core app free of plugin-specific knowledge. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum ProviderType { - /// Built-in: Desktop applications from XDG directories - Application, - /// Built-in: Shell commands from PATH - Command, - /// Built-in: Pipe-based input (dmenu compatibility) - Dmenu, - /// Plugin-defined provider type with its type_id (e.g., "calc", "weather", "emoji") - Plugin(String), -} - -impl std::str::FromStr for ProviderType { - type Err = String; - - 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), - "dmenu" => Ok(ProviderType::Dmenu), - // Everything else is a plugin - 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 -pub trait Provider: Send { - #[allow(dead_code)] - fn name(&self) -> &str; - fn provider_type(&self) -> ProviderType; - fn refresh(&mut self); - fn items(&self) -> &[LaunchItem]; -} - -/// Manages all providers and handles searching -pub struct ProviderManager { - /// Core static providers (apps, commands, dmenu) - providers: 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 - matcher: SkimMatcherV2, -} - -impl ProviderManager { - /// Create a new ProviderManager with native plugins - /// - /// Native plugins are loaded from /usr/lib/owlry/plugins/ and categorized based on - /// their declared ProviderKind and ProviderPosition: - /// - Static providers with Normal position (added to providers vec) - /// - Dynamic providers (queried per-keystroke, declared via ProviderKind::Dynamic) - /// - Widget providers (shown at top, declared via ProviderPosition::Widget) - pub fn with_native_plugins(native_providers: Vec) -> Self { - let mut manager = Self { - providers: Vec::new(), - static_native_providers: Vec::new(), - dynamic_providers: Vec::new(), - widget_providers: Vec::new(), - matcher: SkimMatcherV2::default(), - }; - - // Check if running in dmenu mode (stdin has data) - let dmenu_mode = DmenuProvider::has_stdin_data(); - - if dmenu_mode { - // In dmenu mode, only use dmenu provider - let mut dmenu = DmenuProvider::new(); - dmenu.enable(); - manager.providers.push(Box::new(dmenu)); - } else { - // Core providers (no plugin equivalents) - manager.providers.push(Box::new(ApplicationProvider::new())); - manager.providers.push(Box::new(CommandProvider::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() { - // Dynamic providers declare ProviderKind::Dynamic - info!("Registered dynamic provider: {} ({})", provider.name(), type_id); - manager.dynamic_providers.push(provider); - } else if provider.is_widget() { - // Widgets declare ProviderPosition::Widget - info!("Registered widget provider: {} ({})", provider.name(), type_id); - manager.widget_providers.push(provider); - } else { - // Static native providers (keep as NativeProvider for query/submenu support) - info!("Registered static provider: {} ({})", provider.name(), type_id); - manager.static_native_providers.push(provider); - } - } - } - - // Initial refresh - manager.refresh_all(); - - manager - } - - #[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) { - // Refresh core providers (apps, commands) - for provider in &mut self.providers { - provider.refresh(); - info!( - "Provider '{}' loaded {} items", - provider.name(), - 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) - } - - /// 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() - ); - } - } - - /// 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 - 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); - 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() - .take(max_results) - .map(|item| (item.clone(), 0)) - .collect(); - } - - let mut results: Vec<(LaunchItem, i64)> = self.all_static_items() - .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, 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 - pub fn search_filtered( - &self, - query: &str, - max_results: usize, - filter: &crate::filter::ProviderFilter, - ) -> Vec<(LaunchItem, i64)> { - // Collect items from core providers - let core_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()); - - if query.is_empty() { - return core_items - .chain(native_items) - .take(max_results) - .map(|item| (item, 0)) - .collect(); - } - - let mut results: Vec<(LaunchItem, i64)> = core_items - .chain(native_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 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) - if filter.active_prefix().is_none() && query.is_empty() { - // Widget priority comes from plugin-declared priority field - for provider in &self.widget_providers { - let base_score = provider.priority() as i64; - for (idx, item) in provider.items().iter().enumerate() { - results.push((item.clone(), base_score - idx as i64)); - } - } - } - - // 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) - 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; - for (idx, item) in dynamic_results.into_iter().enumerate() { - results.push((item, base_score - idx as i64)); - } - } - } - - // Empty query (after checking special providers) - return frecency-sorted items - if query.is_empty() { - // Collect items from core providers - let core_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 items: Vec<(LaunchItem, i64)> = core_items - .chain(native_items) - .filter(|item| { - // Apply tag filter if present - 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(&item.id); - let boosted = (frecency_score * frecency_weight * 100.0) as i64; - (item, boosted) - }) - .collect(); - - // Combine widgets (already in results) with frecency items - results.extend(items); - 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 - let score_item = |item: &LaunchItem| -> Option<(LaunchItem, i64)> { - // 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 - - 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(&item.id); - let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64; - (item.clone(), s + frecency_boost) - }) - }; - - // Search core providers - for provider in &self.providers { - if !filter.is_active(provider.provider_type()) { - continue; - } - for item in provider.items() { - if let Some(scored) = score_item(item) { - results.push(scored); - } - } - } - - // Search static native providers - for provider in &self.static_native_providers { - if !filter.is_active(provider.provider_type()) { - continue; - } - for item in provider.items() { - if let Some(scored) = score_item(item) { - results.push(scored); - } - } - } - 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_providers(&self) -> Vec { - self.providers - .iter() - .map(|p| p.provider_type()) - .chain(self.static_native_providers.iter().map(|p| p.provider_type())) - .collect() - } - - /// 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 - /// - /// 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. - 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 - ); - - // 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); - if !actions.is_empty() { - return Some((display_name.to_string(), actions)); - } - } - } - - #[cfg(feature = "dev-logging")] - debug!("[Submenu] No submenu actions found for plugin '{}'", plugin_id); - - None - } -} diff --git a/crates/owlry/src/providers/native_provider.rs b/crates/owlry/src/providers/native_provider.rs deleted file mode 100644 index acda16b..0000000 --- a/crates/owlry/src/providers/native_provider.rs +++ /dev/null @@ -1,197 +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, RwLock}; - -use log::debug; -use owlry_plugin_api::{PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition}; - -use super::{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: RwLock>, -} - -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: RwLock::new(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()) - } - - /// 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(), - } - } - - /// 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.read().unwrap().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 - } - - /// 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.write().unwrap() = items; - } - - fn items(&self) -> &[LaunchItem] { - // This is tricky with RwLock - we need to return a reference but can't - // hold the lock across the return. We use a raw pointer approach. - // - // SAFETY: The items Vec is only modified during refresh() which takes - // &mut self, so no concurrent modification can occur while this - // reference is live. - unsafe { - let guard = self.items.read().unwrap(); - let ptr = guard.as_ptr(); - let len = guard.len(); - std::slice::from_raw_parts(ptr, len) - } - } -} - -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/src/theme.rs b/crates/owlry/src/theme.rs index a2a6502..fd3b01b 100644 --- a/crates/owlry/src/theme.rs +++ b/crates/owlry/src/theme.rs @@ -1,4 +1,4 @@ -use crate::config::AppearanceConfig; +use owlry_core::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 e41380a..792250a 100644 --- a/crates/owlry/src/ui/main_window.rs +++ b/crates/owlry/src/ui/main_window.rs @@ -1,7 +1,7 @@ -use crate::config::Config; -use crate::data::FrecencyStore; -use crate::filter::ProviderFilter; -use crate::providers::{LaunchItem, ProviderManager, ProviderType}; +use owlry_core::config::Config; +use owlry_core::data::FrecencyStore; +use owlry_core::filter::ProviderFilter; +use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType}; use crate::ui::submenu; use crate::ui::ResultRow; use gtk4::gdk::Key; @@ -408,7 +408,7 @@ impl MainWindow { } /// Build dynamic hints based on enabled providers - fn build_hints(config: &crate::config::ProvidersConfig) -> String { + fn build_hints(config: &owlry_core::config::ProvidersConfig) -> String { let mut parts: Vec = vec![ "Tab: cycle".to_string(), "↑↓: nav".to_string(), @@ -1337,7 +1337,7 @@ impl MainWindow { if let Err(e) = result { let msg = format!("Failed to launch '{}': {}", item.name, e); log::error!("{}", msg); - crate::notify::notify("Launch failed", &msg); + owlry_core::notify::notify("Launch failed", &msg); } } @@ -1355,7 +1355,7 @@ impl MainWindow { if !Path::new(desktop_path).exists() { let msg = format!("Desktop file not found: {}", desktop_path); log::error!("{}", msg); - crate::notify::notify("Launch failed", &msg); + owlry_core::notify::notify("Launch failed", &msg); return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg)); } @@ -1372,7 +1372,7 @@ impl MainWindow { if !uwsm_available { let msg = "uwsm is enabled in config but not installed"; log::error!("{}", msg); - crate::notify::notify("Launch failed", msg); + owlry_core::notify::notify("Launch failed", msg); return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg)); } diff --git a/crates/owlry/src/ui/result_row.rs b/crates/owlry/src/ui/result_row.rs index 8d5abec..175bba8 100644 --- a/crates/owlry/src/ui/result_row.rs +++ b/crates/owlry/src/ui/result_row.rs @@ -1,4 +1,4 @@ -use crate::providers::LaunchItem; +use owlry_core::providers::LaunchItem; use gtk4::prelude::*; use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget}; @@ -81,11 +81,11 @@ 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::Command => "utilities-terminal-symbolic", - crate::providers::ProviderType::Dmenu => "view-list-symbolic", + owlry_core::providers::ProviderType::Application => "application-x-executable-symbolic", + owlry_core::providers::ProviderType::Command => "utilities-terminal-symbolic", + owlry_core::providers::ProviderType::Dmenu => "view-list-symbolic", // Plugins should provide their own icon; fallback to generic addon icon - crate::providers::ProviderType::Plugin(_) => "application-x-addon-symbolic", + owlry_core::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 6075428..b760733 100644 --- a/crates/owlry/src/ui/submenu.rs +++ b/crates/owlry/src/ui/submenu.rs @@ -46,7 +46,7 @@ //! } //! ``` -use crate::providers::LaunchItem; +use owlry_core::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 crate::providers::ProviderType; + use owlry_core::providers::ProviderType; #[test] fn test_parse_submenu_command() { From 1bce5850a39a9f63b240175a573467872e07ab81 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 12:14:37 +0100 Subject: [PATCH 06/20] chore: update justfile for owlry-core crate --- justfile | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/justfile b/justfile index 65d9ca3..8b6083e 100644 --- a/justfile +++ b/justfile @@ -8,10 +8,22 @@ default: build: cargo build --workspace -# Build core binary only -build-core: +# Build UI binary only +build-ui: cargo build -p owlry +# Build core daemon only +build-daemon: + cargo build -p owlry-core + +# Build core daemon release +release-daemon: + cargo build -p owlry-core --release + +# Run core daemon +run-daemon *ARGS: + cargo run -p owlry-core -- {{ARGS}} + # Build release release: cargo build --workspace --release @@ -43,7 +55,7 @@ plugin name: # Build all plugins plugins: - cargo build --workspace --release --exclude owlry + cargo build --workspace --release --exclude owlry --exclude owlry-core # Install locally (core + plugins + runtimes) install-local: @@ -51,10 +63,12 @@ install-local: set -euo pipefail echo "Building release..." - # Build core without embedded Lua (smaller binary) + # Build UI without embedded Lua (smaller binary) cargo build -p owlry --release --no-default-features + # Build core daemon + cargo build -p owlry-core --release # Build plugins - cargo build --workspace --release --exclude owlry + cargo build --workspace --release --exclude owlry --exclude owlry-core echo "Creating directories..." sudo mkdir -p /usr/lib/owlry/plugins @@ -72,8 +86,9 @@ install-local: /usr/lib/owlry/plugins/libsystemd.so /usr/lib/owlry/plugins/libweather.so \ /usr/lib/owlry/plugins/libwebsearch.so - echo "Installing core binary..." + echo "Installing binaries..." sudo install -Dm755 target/release/owlry /usr/bin/owlry + sudo install -Dm755 target/release/owlry-core /usr/bin/owlry-core echo "Installing plugins..." for plugin in target/release/libowlry_plugin_*.so; do @@ -96,7 +111,8 @@ install-local: echo "" echo "Installation complete!" - echo " - /usr/bin/owlry" + echo " - /usr/bin/owlry (UI)" + echo " - /usr/bin/owlry-core (daemon)" echo " - $(ls /usr/lib/owlry/plugins/*.so 2>/dev/null | wc -l) plugins" echo " - $(ls /usr/lib/owlry/runtimes/*.so 2>/dev/null | wc -l) runtimes" @@ -183,13 +199,22 @@ bump-meta new_version: bump-all new_version: #!/usr/bin/env bash set -euo pipefail - # Bump core + # Bump core (UI) toml="crates/owlry/Cargo.toml" old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') if [ "$old" != "{{new_version}}" ]; then echo "Bumping owlry from $old to {{new_version}}" sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml" fi + # Bump core daemon + toml="crates/owlry-core/Cargo.toml" + if [ -f "$toml" ]; then + old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') + if [ "$old" != "{{new_version}}" ]; then + echo "Bumping owlry-core from $old to {{new_version}}" + sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml" + fi + fi # Bump plugins (including plugin-api) for toml in crates/owlry-plugin-*/Cargo.toml; do crate=$(basename $(dirname "$toml")) From 71d78ce7dfcac0a4a466fbf131c8c1193f334383 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 12:17:16 +0100 Subject: [PATCH 07/20] feat(owlry-core): define IPC message types with serde --- crates/owlry-core/src/ipc.rs | 66 +++++++++++++++++ crates/owlry-core/src/lib.rs | 1 + crates/owlry-core/tests/ipc_test.rs | 109 ++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 crates/owlry-core/src/ipc.rs create mode 100644 crates/owlry-core/tests/ipc_test.rs diff --git a/crates/owlry-core/src/ipc.rs b/crates/owlry-core/src/ipc.rs new file mode 100644 index 0000000..0090ade --- /dev/null +++ b/crates/owlry-core/src/ipc.rs @@ -0,0 +1,66 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Request { + Query { + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + modes: Option>, + }, + Launch { + item_id: String, + provider: String, + }, + Providers, + Refresh { + provider: String, + }, + Toggle, + Submenu { + plugin_id: String, + data: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Response { + Results { + items: Vec, + }, + Providers { + list: Vec, + }, + SubmenuItems { + items: Vec, + }, + Ack, + Error { + message: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ResultItem { + pub id: String, + pub title: String, + pub description: String, + pub icon: String, + pub provider: String, + pub score: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProviderDesc { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub prefix: Option, + pub icon: String, + pub position: String, +} diff --git a/crates/owlry-core/src/lib.rs b/crates/owlry-core/src/lib.rs index 7396113..dfedc76 100644 --- a/crates/owlry-core/src/lib.rs +++ b/crates/owlry-core/src/lib.rs @@ -1,6 +1,7 @@ pub mod config; pub mod data; pub mod filter; +pub mod ipc; pub mod notify; pub mod paths; pub mod plugins; diff --git a/crates/owlry-core/tests/ipc_test.rs b/crates/owlry-core/tests/ipc_test.rs new file mode 100644 index 0000000..79928bb --- /dev/null +++ b/crates/owlry-core/tests/ipc_test.rs @@ -0,0 +1,109 @@ +use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem}; + +#[test] +fn test_query_request_roundtrip() { + let req = Request::Query { + text: "fire".into(), + modes: Some(vec!["app".into(), "cmd".into()]), + }; + let json = serde_json::to_string(&req).unwrap(); + let parsed: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(req, parsed); +} + +#[test] +fn test_query_request_without_modes() { + let req = Request::Query { + text: "fire".into(), + modes: None, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(!json.contains("modes")); + let parsed: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(req, parsed); +} + +#[test] +fn test_launch_request_roundtrip() { + let req = Request::Launch { + item_id: "firefox.desktop".into(), + provider: "app".into(), + }; + let json = serde_json::to_string(&req).unwrap(); + let parsed: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(req, parsed); +} + +#[test] +fn test_results_response_roundtrip() { + let resp = Response::Results { + items: vec![ResultItem { + id: "firefox.desktop".into(), + title: "Firefox".into(), + description: "Web Browser".into(), + icon: "firefox".into(), + provider: "app".into(), + score: 95, + command: Some("firefox".into()), + tags: vec![], + }], + }; + let json = serde_json::to_string(&resp).unwrap(); + let parsed: Response = serde_json::from_str(&json).unwrap(); + assert_eq!(resp, parsed); +} + +#[test] +fn test_providers_response() { + let resp = Response::Providers { + list: vec![ProviderDesc { + id: "app".into(), + name: "Applications".into(), + prefix: Some(":app".into()), + icon: "application-x-executable".into(), + position: "normal".into(), + }], + }; + let json = serde_json::to_string(&resp).unwrap(); + let parsed: Response = serde_json::from_str(&json).unwrap(); + assert_eq!(resp, parsed); +} + +#[test] +fn test_error_response() { + let resp = Response::Error { + message: "plugin not found".into(), + }; + let json = serde_json::to_string(&resp).unwrap(); + let parsed: Response = serde_json::from_str(&json).unwrap(); + assert_eq!(resp, parsed); +} + +#[test] +fn test_toggle_request() { + let req = Request::Toggle; + let json = serde_json::to_string(&req).unwrap(); + let parsed: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(req, parsed); +} + +#[test] +fn test_submenu_request() { + let req = Request::Submenu { + plugin_id: "systemd".into(), + data: "docker.service".into(), + }; + let json = serde_json::to_string(&req).unwrap(); + let parsed: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(req, parsed); +} + +#[test] +fn test_refresh_request() { + let req = Request::Refresh { + provider: "clipboard".into(), + }; + let json = serde_json::to_string(&req).unwrap(); + let parsed: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(req, parsed); +} From 915dc193d9fe25c8e26b8b48006a45785cd79bc5 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 12:22:37 +0100 Subject: [PATCH 08/20] feat(owlry-core): add daemon-friendly API to ProviderManager and ProviderFilter Add methods needed by the IPC server (Task 9) to create filters from mode strings, query provider metadata, and refresh individual providers. ProviderFilter: - from_mode_strings(): create filter from ["app", "cmd", "calc"] etc. - all(): create permissive filter accepting all provider types - mode_string_to_provider_type(): public helper for string-to-type mapping ProviderManager: - ProviderDescriptor struct for IPC provider metadata responses - available_providers() -> Vec (replaces ProviderType version) - refresh_provider(id): refresh a single provider by type_id - new_with_config(config): self-contained init for daemon use NativeProvider: - icon(): get provider's default icon name - position_str(): get position as "normal"/"widget" string --- crates/owlry-core/src/filter.rs | 158 +++++++++ crates/owlry-core/src/providers/mod.rs | 325 +++++++++++++++++- .../src/providers/native_provider.rs | 13 + 3 files changed, 495 insertions(+), 1 deletion(-) diff --git a/crates/owlry-core/src/filter.rs b/crates/owlry-core/src/filter.rs index b9e231e..df9d303 100644 --- a/crates/owlry-core/src/filter.rs +++ b/crates/owlry-core/src/filter.rs @@ -342,6 +342,61 @@ impl ProviderFilter { providers } + /// Create a filter from a list of mode name strings. + /// + /// Maps each string to a ProviderType: "app" -> Application, "cmd" -> Command, + /// "dmenu" -> Dmenu, anything else -> Plugin(id). An empty list produces an + /// all-providers filter. + pub fn from_mode_strings(modes: &[String]) -> Self { + if modes.is_empty() { + return Self::all(); + } + let enabled: HashSet = modes + .iter() + .map(|s| Self::mode_string_to_provider_type(s)) + .collect(); + Self { + enabled, + active_prefix: None, + } + } + + /// Create a filter that accepts all providers. + /// + /// Internally enables Application, Command, and Dmenu. Plugin providers are + /// implicitly accepted because `is_active` will match them when they appear + /// in the enabled set. For a true "pass everything" filter, this also + /// pre-populates common plugin types. + /// + /// The daemon uses this as the default when no modes are specified. + pub fn all() -> Self { + let mut enabled = HashSet::new(); + enabled.insert(ProviderType::Application); + enabled.insert(ProviderType::Command); + enabled.insert(ProviderType::Dmenu); + // Common plugin types — the daemon typically has all plugins loaded + for id in &[ + "calc", "clipboard", "emoji", "bookmarks", "ssh", "scripts", + "system", "uuctl", "filesearch", "websearch", "weather", + "media", "pomodoro", + ] { + enabled.insert(ProviderType::Plugin(id.to_string())); + } + Self { + enabled, + active_prefix: None, + } + } + + /// Map a mode string to a ProviderType. + /// + /// Delegates to the existing `FromStr` impl on `ProviderType` which maps + /// "app"/"apps"/"application" -> Application, "cmd"/"command" -> Command, + /// "dmenu" -> Dmenu, and everything else -> Plugin(id). + pub fn mode_string_to_provider_type(mode: &str) -> ProviderType { + mode.parse::().unwrap_or_else(|_| ProviderType::Plugin(mode.to_string())) + } + /// Get display name for current mode pub fn mode_display_name(&self) -> &'static str { if let Some(ref prefix) = self.active_prefix { @@ -406,4 +461,107 @@ mod tests { // Should still have apps enabled as fallback assert!(filter.is_enabled(ProviderType::Application)); } + + #[test] + fn test_from_mode_strings_single_core() { + let filter = ProviderFilter::from_mode_strings(&["app".to_string()]); + assert!(filter.is_enabled(ProviderType::Application)); + assert!(!filter.is_enabled(ProviderType::Command)); + } + + #[test] + fn test_from_mode_strings_multiple() { + let filter = ProviderFilter::from_mode_strings(&[ + "app".to_string(), + "cmd".to_string(), + "calc".to_string(), + ]); + assert!(filter.is_enabled(ProviderType::Application)); + assert!(filter.is_enabled(ProviderType::Command)); + assert!(filter.is_enabled(ProviderType::Plugin("calc".to_string()))); + assert!(!filter.is_enabled(ProviderType::Dmenu)); + } + + #[test] + fn test_from_mode_strings_empty_returns_all() { + let filter = ProviderFilter::from_mode_strings(&[]); + assert!(filter.is_enabled(ProviderType::Application)); + assert!(filter.is_enabled(ProviderType::Command)); + assert!(filter.is_enabled(ProviderType::Dmenu)); + } + + #[test] + fn test_from_mode_strings_plugin() { + let filter = ProviderFilter::from_mode_strings(&["emoji".to_string()]); + assert!(filter.is_enabled(ProviderType::Plugin("emoji".to_string()))); + assert!(!filter.is_enabled(ProviderType::Application)); + } + + #[test] + fn test_from_mode_strings_dmenu() { + let filter = ProviderFilter::from_mode_strings(&["dmenu".to_string()]); + assert!(filter.is_enabled(ProviderType::Dmenu)); + assert!(!filter.is_enabled(ProviderType::Application)); + } + + #[test] + fn test_all_includes_core_types() { + let filter = ProviderFilter::all(); + assert!(filter.is_enabled(ProviderType::Application)); + assert!(filter.is_enabled(ProviderType::Command)); + assert!(filter.is_enabled(ProviderType::Dmenu)); + } + + #[test] + fn test_all_includes_common_plugins() { + let filter = ProviderFilter::all(); + assert!(filter.is_enabled(ProviderType::Plugin("calc".to_string()))); + assert!(filter.is_enabled(ProviderType::Plugin("clipboard".to_string()))); + assert!(filter.is_enabled(ProviderType::Plugin("emoji".to_string()))); + assert!(filter.is_enabled(ProviderType::Plugin("weather".to_string()))); + } + + #[test] + fn test_mode_string_to_provider_type_core() { + assert_eq!( + ProviderFilter::mode_string_to_provider_type("app"), + ProviderType::Application + ); + assert_eq!( + ProviderFilter::mode_string_to_provider_type("cmd"), + ProviderType::Command + ); + assert_eq!( + ProviderFilter::mode_string_to_provider_type("dmenu"), + ProviderType::Dmenu + ); + } + + #[test] + fn test_mode_string_to_provider_type_plugin() { + assert_eq!( + ProviderFilter::mode_string_to_provider_type("calc"), + ProviderType::Plugin("calc".to_string()) + ); + assert_eq!( + ProviderFilter::mode_string_to_provider_type("websearch"), + ProviderType::Plugin("websearch".to_string()) + ); + } + + #[test] + fn test_mode_string_to_provider_type_aliases() { + assert_eq!( + ProviderFilter::mode_string_to_provider_type("apps"), + ProviderType::Application + ); + assert_eq!( + ProviderFilter::mode_string_to_provider_type("application"), + ProviderType::Application + ); + assert_eq!( + ProviderFilter::mode_string_to_provider_type("command"), + ProviderType::Command + ); + } } diff --git a/crates/owlry-core/src/providers/mod.rs b/crates/owlry-core/src/providers/mod.rs index 3e6e472..9aef46e 100644 --- a/crates/owlry-core/src/providers/mod.rs +++ b/crates/owlry-core/src/providers/mod.rs @@ -23,8 +23,19 @@ use log::info; #[cfg(feature = "dev-logging")] use log::debug; +use crate::config::Config; use crate::data::FrecencyStore; +/// 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, +} + /// Represents a single searchable/launchable item #[derive(Debug, Clone)] pub struct LaunchItem { @@ -147,6 +158,59 @@ impl ProviderManager { manager } + /// Create a self-contained ProviderManager 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`. + pub fn new_with_config(config: &Config) -> Self { + use crate::plugins::native_loader::NativePluginLoader; + use std::sync::Arc; + + // Create core providers + let core_providers: Vec> = vec![ + Box::new(ApplicationProvider::new()), + Box::new(CommandProvider::new()), + ]; + + // Load native plugins + let mut loader = NativePluginLoader::new(); + loader.set_disabled(config.plugins.disabled_plugins.clone()); + + 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() + } + }; + + Self::new(core_providers, native_providers) + } + #[allow(dead_code)] pub fn is_dmenu_mode(&self) -> bool { self.providers @@ -515,7 +579,7 @@ impl ProviderManager { /// Get all available provider types (for UI tabs) #[allow(dead_code)] - pub fn available_providers(&self) -> Vec { + pub fn available_provider_types(&self) -> Vec { self.providers .iter() .map(|p| p.provider_type()) @@ -523,6 +587,122 @@ impl ProviderManager { .collect() } + /// Get descriptors for all registered providers (core + native plugins). + /// + /// 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() { + ProviderType::Application => ( + "app".to_string(), + Some(":app".to_string()), + "application-x-executable".to_string(), + ), + ProviderType::Command => ( + "cmd".to_string(), + Some(":cmd".to_string()), + "utilities-terminal".to_string(), + ), + ProviderType::Dmenu => ( + "dmenu".to_string(), + None, + "view-list-symbolic".to_string(), + ), + ProviderType::Plugin(type_id) => ( + type_id, + None, + "application-x-addon".to_string(), + ), + }; + descs.push(ProviderDescriptor { + id, + name: provider.name().to_string(), + prefix, + icon, + position: "normal".to_string(), + }); + } + + // 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(), + }); + } + + // 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(), + }); + } + + // 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(), + }); + } + + 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", + ProviderType::Command => provider_id == "cmd", + ProviderType::Dmenu => provider_id == "dmenu", + ProviderType::Plugin(ref id) => provider_id == id, + }; + if matches { + provider.refresh(); + info!("Refreshed core 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 { @@ -596,3 +776,146 @@ impl ProviderManager { 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(), + } + } + + #[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()); + + // 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 + } + + #[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"); + // Should complete without panicking + } + + #[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"); + } +} diff --git a/crates/owlry-core/src/providers/native_provider.rs b/crates/owlry-core/src/providers/native_provider.rs index acda16b..3aabe9b 100644 --- a/crates/owlry-core/src/providers/native_provider.rs +++ b/crates/owlry-core/src/providers/native_provider.rs @@ -116,6 +116,19 @@ impl NativeProvider { 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) { From f609ce1c13a9213883518b0ec102b5a55bd7e38a Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 12:26:06 +0100 Subject: [PATCH 09/20] feat(owlry-core): implement IPC server over Unix socket Adds Server struct that listens on a Unix domain socket, accepts client connections (thread-per-client), reads newline-delimited JSON requests, dispatches to ProviderManager/FrecencyStore/Config, and sends JSON responses back. Includes stale socket cleanup and Drop impl for socket removal. --- crates/owlry-core/src/lib.rs | 1 + crates/owlry-core/src/server.rs | 237 +++++++++++++++++++++++++ crates/owlry-core/tests/server_test.rs | 229 ++++++++++++++++++++++++ 3 files changed, 467 insertions(+) create mode 100644 crates/owlry-core/src/server.rs create mode 100644 crates/owlry-core/tests/server_test.rs diff --git a/crates/owlry-core/src/lib.rs b/crates/owlry-core/src/lib.rs index dfedc76..5ff1948 100644 --- a/crates/owlry-core/src/lib.rs +++ b/crates/owlry-core/src/lib.rs @@ -6,3 +6,4 @@ pub mod notify; pub mod paths; pub mod plugins; pub mod providers; +pub mod server; diff --git a/crates/owlry-core/src/server.rs b/crates/owlry-core/src/server.rs new file mode 100644 index 0000000..7b9f93e --- /dev/null +++ b/crates/owlry-core/src/server.rs @@ -0,0 +1,237 @@ +use std::io::{self, BufRead, BufReader, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::thread; + +use log::{error, info, warn}; + +use crate::config::Config; +use crate::data::FrecencyStore; +use crate::filter::ProviderFilter; +use crate::ipc::{ProviderDesc, Request, Response, ResultItem}; +use crate::providers::{LaunchItem, ProviderManager}; + +/// IPC server that listens on a Unix domain socket and dispatches +/// requests to the provider system. +pub struct Server { + listener: UnixListener, + socket_path: PathBuf, + provider_manager: Arc>, + frecency: Arc>, + config: Arc, +} + +impl Server { + /// Bind to the given socket path, loading config and creating a ProviderManager. + /// + /// Removes a stale socket file if one already exists at the path. + pub fn bind(socket_path: &Path) -> io::Result { + // Remove stale socket if present + if socket_path.exists() { + info!("Removing stale socket at {:?}", socket_path); + std::fs::remove_file(socket_path)?; + } + + let listener = UnixListener::bind(socket_path)?; + info!("IPC server listening on {:?}", socket_path); + + let config = Config::load_or_default(); + let provider_manager = ProviderManager::new_with_config(&config); + let frecency = FrecencyStore::new(); + + Ok(Self { + listener, + socket_path: socket_path.to_path_buf(), + provider_manager: Arc::new(Mutex::new(provider_manager)), + frecency: Arc::new(Mutex::new(frecency)), + config: Arc::new(config), + }) + } + + /// Accept connections in a loop, spawning a thread per client. + pub fn run(&self) -> io::Result<()> { + info!("Server entering accept loop"); + for stream in self.listener.incoming() { + match stream { + Ok(stream) => { + let pm = Arc::clone(&self.provider_manager); + let frecency = Arc::clone(&self.frecency); + let config = Arc::clone(&self.config); + thread::spawn(move || { + if let Err(e) = Self::handle_client(stream, pm, frecency, config) { + warn!("Client handler error: {}", e); + } + }); + } + Err(e) => { + error!("Failed to accept connection: {}", e); + } + } + } + Ok(()) + } + + /// Accept one connection and handle all its requests until EOF. + /// + /// Intended for integration tests where spawning a full accept loop + /// is unnecessary. + pub fn handle_one_for_testing(&self) -> io::Result<()> { + let (stream, _addr) = self.listener.accept()?; + Self::handle_client( + stream, + Arc::clone(&self.provider_manager), + Arc::clone(&self.frecency), + Arc::clone(&self.config), + ) + } + + /// Read newline-delimited JSON requests from a single client stream, + /// dispatch each, and write the JSON response back. + fn handle_client( + stream: UnixStream, + pm: Arc>, + frecency: Arc>, + config: Arc, + ) -> io::Result<()> { + let reader = BufReader::new(stream.try_clone()?); + let mut writer = stream; + + for line in reader.lines() { + let line = line?; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let request: Request = match serde_json::from_str(trimmed) { + Ok(req) => req, + Err(e) => { + let resp = Response::Error { + message: format!("invalid request JSON: {}", e), + }; + write_response(&mut writer, &resp)?; + continue; + } + }; + + let response = Self::handle_request(&request, &pm, &frecency, &config); + write_response(&mut writer, &response)?; + } + + Ok(()) + } + + /// Dispatch a single request to the appropriate subsystem and return + /// the response. + fn handle_request( + request: &Request, + pm: &Arc>, + frecency: &Arc>, + config: &Arc, + ) -> Response { + match request { + Request::Query { text, modes } => { + let filter = match modes { + Some(m) => ProviderFilter::from_mode_strings(m), + None => ProviderFilter::all(), + }; + let max = config.general.max_results; + let weight = config.providers.frecency_weight; + + let pm_guard = pm.lock().unwrap(); + let frecency_guard = frecency.lock().unwrap(); + let results = + pm_guard.search_with_frecency(text, max, &filter, &frecency_guard, weight, None); + + Response::Results { + items: results + .into_iter() + .map(|(item, score)| launch_item_to_result(item, score)) + .collect(), + } + } + + Request::Launch { item_id, provider: _ } => { + let mut frecency_guard = frecency.lock().unwrap(); + frecency_guard.record_launch(item_id); + Response::Ack + } + + Request::Providers => { + let pm_guard = pm.lock().unwrap(); + let descs = pm_guard.available_providers(); + Response::Providers { + list: descs.into_iter().map(descriptor_to_desc).collect(), + } + } + + Request::Refresh { provider } => { + let mut pm_guard = pm.lock().unwrap(); + pm_guard.refresh_provider(provider); + Response::Ack + } + + Request::Toggle => { + // Toggle visibility is a client-side concern; the daemon just acks. + Response::Ack + } + + Request::Submenu { plugin_id, data } => { + let pm_guard = pm.lock().unwrap(); + match pm_guard.query_submenu_actions(plugin_id, data, plugin_id) { + Some((_name, actions)) => Response::SubmenuItems { + items: actions + .into_iter() + .map(|item| launch_item_to_result(item, 0)) + .collect(), + }, + None => Response::Error { + message: format!("no submenu actions for plugin '{}'", plugin_id), + }, + } + } + } + } +} + +impl Drop for Server { + fn drop(&mut self) { + // Best-effort cleanup of the socket file + if self.socket_path.exists() { + let _ = std::fs::remove_file(&self.socket_path); + } + } +} + +/// Serialize a response as a single JSON line terminated by newline. +fn write_response(writer: &mut UnixStream, response: &Response) -> io::Result<()> { + let mut json = serde_json::to_string(response) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + json.push('\n'); + writer.write_all(json.as_bytes())?; + writer.flush() +} + +fn launch_item_to_result(item: LaunchItem, score: i64) -> ResultItem { + ResultItem { + id: item.id, + title: item.name, + description: item.description.unwrap_or_default(), + icon: item.icon.unwrap_or_default(), + provider: format!("{}", item.provider), + score, + command: Some(item.command), + tags: item.tags, + } +} + +fn descriptor_to_desc(desc: crate::providers::ProviderDescriptor) -> ProviderDesc { + ProviderDesc { + id: desc.id, + name: desc.name, + prefix: desc.prefix, + icon: desc.icon, + position: desc.position, + } +} diff --git a/crates/owlry-core/tests/server_test.rs b/crates/owlry-core/tests/server_test.rs new file mode 100644 index 0000000..73b7e26 --- /dev/null +++ b/crates/owlry-core/tests/server_test.rs @@ -0,0 +1,229 @@ +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; + +/// Helper: send a JSON request line and read the JSON response line. +fn roundtrip(stream: &mut UnixStream, request: &Request) -> Response { + let mut line = serde_json::to_string(request).unwrap(); + line.push('\n'); + stream.write_all(line.as_bytes()).unwrap(); + stream.flush().unwrap(); + + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut buf = String::new(); + reader.read_line(&mut buf).unwrap(); + serde_json::from_str(buf.trim()).unwrap() +} + +#[test] +fn test_server_responds_to_providers_request() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("owlry-test.sock"); + + let server = Server::bind(&sock).unwrap(); + + // Spawn the server to handle exactly one connection + let handle = thread::spawn(move || { + server.handle_one_for_testing().unwrap(); + }); + + // Connect as a client + let mut stream = UnixStream::connect(&sock).unwrap(); + let resp = roundtrip(&mut stream, &Request::Providers); + + match resp { + Response::Providers { list } => { + // The default ProviderManager always has at least Application and Command + assert!(list.len() >= 2, "expected at least 2 providers, got {}", list.len()); + let ids: Vec<&str> = list.iter().map(|p| p.id.as_str()).collect(); + assert!(ids.contains(&"app"), "missing 'app' provider"); + assert!(ids.contains(&"cmd"), "missing 'cmd' provider"); + } + other => panic!("expected Providers response, got: {:?}", other), + } + + drop(stream); + handle.join().unwrap(); +} + +#[test] +fn test_server_handles_launch_request() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("owlry-test.sock"); + + let server = Server::bind(&sock).unwrap(); + + let handle = thread::spawn(move || { + server.handle_one_for_testing().unwrap(); + }); + + let mut stream = UnixStream::connect(&sock).unwrap(); + let req = Request::Launch { + item_id: "firefox.desktop".into(), + provider: "app".into(), + }; + let resp = roundtrip(&mut stream, &req); + + assert_eq!(resp, Response::Ack); + + drop(stream); + handle.join().unwrap(); +} + +#[test] +fn test_server_handles_query_request() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("owlry-test.sock"); + + let server = Server::bind(&sock).unwrap(); + + let handle = thread::spawn(move || { + server.handle_one_for_testing().unwrap(); + }); + + let mut stream = UnixStream::connect(&sock).unwrap(); + let req = Request::Query { + text: "nonexistent_query_xyz".into(), + modes: None, + }; + let resp = roundtrip(&mut stream, &req); + + match resp { + Response::Results { items } => { + // A nonsense query should return empty or very few results + // (no items will fuzzy-match "nonexistent_query_xyz") + assert!(items.len() <= 5, "expected few/no results for gibberish query"); + } + other => panic!("expected Results response, got: {:?}", other), + } + + drop(stream); + handle.join().unwrap(); +} + +#[test] +fn test_server_handles_toggle_request() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("owlry-test.sock"); + + let server = Server::bind(&sock).unwrap(); + + let handle = thread::spawn(move || { + server.handle_one_for_testing().unwrap(); + }); + + let mut stream = UnixStream::connect(&sock).unwrap(); + let resp = roundtrip(&mut stream, &Request::Toggle); + + assert_eq!(resp, Response::Ack); + + drop(stream); + handle.join().unwrap(); +} + +#[test] +fn test_server_handles_refresh_request() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("owlry-test.sock"); + + let server = Server::bind(&sock).unwrap(); + + let handle = thread::spawn(move || { + server.handle_one_for_testing().unwrap(); + }); + + let mut stream = UnixStream::connect(&sock).unwrap(); + let req = Request::Refresh { + provider: "app".into(), + }; + let resp = roundtrip(&mut stream, &req); + + assert_eq!(resp, Response::Ack); + + drop(stream); + handle.join().unwrap(); +} + +#[test] +fn test_server_handles_submenu_for_unknown_plugin() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("owlry-test.sock"); + + let server = Server::bind(&sock).unwrap(); + + let handle = thread::spawn(move || { + server.handle_one_for_testing().unwrap(); + }); + + let mut stream = UnixStream::connect(&sock).unwrap(); + let req = Request::Submenu { + plugin_id: "nonexistent_plugin".into(), + data: "some_data".into(), + }; + let resp = roundtrip(&mut stream, &req); + + match resp { + Response::Error { message } => { + assert!( + message.contains("nonexistent_plugin"), + "error should mention the plugin id" + ); + } + other => panic!("expected Error response for unknown plugin, got: {:?}", other), + } + + drop(stream); + handle.join().unwrap(); +} + +#[test] +fn test_server_handles_multiple_requests_per_connection() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("owlry-test.sock"); + + let server = Server::bind(&sock).unwrap(); + + let handle = thread::spawn(move || { + server.handle_one_for_testing().unwrap(); + }); + + let mut stream = UnixStream::connect(&sock).unwrap(); + + // Send Providers request + let resp1 = roundtrip(&mut stream, &Request::Providers); + assert!(matches!(resp1, Response::Providers { .. })); + + // Send Toggle request on same connection + let resp2 = roundtrip(&mut stream, &Request::Toggle); + assert_eq!(resp2, Response::Ack); + + drop(stream); + handle.join().unwrap(); +} + +#[test] +fn test_server_cleans_up_stale_socket() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("owlry-test.sock"); + + // Create a stale socket file + std::os::unix::net::UnixListener::bind(&sock).unwrap(); + assert!(sock.exists()); + + // Server::bind should succeed by removing the stale socket + let server = Server::bind(&sock).unwrap(); + + let handle = thread::spawn(move || { + server.handle_one_for_testing().unwrap(); + }); + + let mut stream = UnixStream::connect(&sock).unwrap(); + let resp = roundtrip(&mut stream, &Request::Toggle); + assert_eq!(resp, Response::Ack); + + drop(stream); + handle.join().unwrap(); +} From 18c58ce33d94221f40587f5581e34a4305e3dcd5 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 12:28:53 +0100 Subject: [PATCH 10/20] feat(owlry-core): add daemon binary entry point Add [[bin]] target and main.rs that starts the IPC server with env_logger, socket path from XDG_RUNTIME_DIR, and graceful shutdown via ctrlc signal handler. Also add socket_path() to paths module. --- crates/owlry-core/Cargo.toml | 7 +++++++ crates/owlry-core/src/main.rs | 38 ++++++++++++++++++++++++++++++++++ crates/owlry-core/src/paths.rs | 14 +++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 crates/owlry-core/src/main.rs diff --git a/crates/owlry-core/Cargo.toml b/crates/owlry-core/Cargo.toml index a6e5b40..0dc162d 100644 --- a/crates/owlry-core/Cargo.toml +++ b/crates/owlry-core/Cargo.toml @@ -11,6 +11,10 @@ description = "Core daemon for the Owlry application launcher" name = "owlry_core" path = "src/lib.rs" +[[bin]] +name = "owlry-core" +path = "src/main.rs" + [dependencies] owlry-plugin-api = { path = "../owlry-plugin-api" } @@ -32,6 +36,9 @@ dirs = "5" # Error handling thiserror = "2" +# Signal handling +ctrlc = { version = "3", features = ["termination"] } + # Logging & notifications log = "0.4" env_logger = "0.11" diff --git a/crates/owlry-core/src/main.rs b/crates/owlry-core/src/main.rs new file mode 100644 index 0000000..121dcae --- /dev/null +++ b/crates/owlry-core/src/main.rs @@ -0,0 +1,38 @@ +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 owlry-core 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 owlry-core: {e}"); + std::process::exit(1); + } + }; + + // Graceful shutdown on SIGTERM/SIGINT + let sock_cleanup = sock.clone(); + ctrlc::set_handler(move || { + let _ = std::fs::remove_file(&sock_cleanup); + std::process::exit(0); + }) + .ok(); + + if let Err(e) = server.run() { + eprintln!("Server error: {e}"); + std::process::exit(1); + } +} diff --git a/crates/owlry-core/src/paths.rs b/crates/owlry-core/src/paths.rs index a846063..2505f37 100644 --- a/crates/owlry-core/src/paths.rs +++ b/crates/owlry-core/src/paths.rs @@ -154,6 +154,20 @@ pub fn system_data_dirs() -> Vec { dirs } +// ============================================================================= +// Runtime files +// ============================================================================= + +/// IPC socket path: `$XDG_RUNTIME_DIR/owlry/owlry.sock` +/// +/// Falls back to `/tmp` if `$XDG_RUNTIME_DIR` is not set. +pub fn socket_path() -> PathBuf { + let runtime_dir = std::env::var("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/tmp")); + runtime_dir.join(APP_NAME).join("owlry.sock") +} + // ============================================================================= // Helper functions // ============================================================================= From 4ed9a9973a4e19b355500442682b12aa0848ad7e Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 12:33:27 +0100 Subject: [PATCH 11/20] feat(owlry): implement IPC client for daemon communication Add CoreClient struct that connects to the owlry-core daemon Unix socket and provides typed methods for query, launch, providers, toggle, and submenu operations. Reuses owlry_core::paths::socket_path() as the single source of truth for the socket location. Includes connect_or_start() with systemd integration and exponential backoff retry logic. --- crates/owlry/src/client.rs | 379 +++++++++++++++++++++++++++++++++++++ crates/owlry/src/main.rs | 1 + 2 files changed, 380 insertions(+) create mode 100644 crates/owlry/src/client.rs diff --git a/crates/owlry/src/client.rs b/crates/owlry/src/client.rs new file mode 100644 index 0000000..6e4bfa0 --- /dev/null +++ b/crates/owlry/src/client.rs @@ -0,0 +1,379 @@ +use std::io::{self, BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use owlry_core::ipc::{ProviderDesc, Request, Response, ResultItem}; + +/// IPC client that connects to the owlry-core daemon Unix socket +/// and provides typed methods for all IPC operations. +pub struct CoreClient { + stream: UnixStream, + reader: BufReader, +} + +impl CoreClient { + /// Connect to a running daemon at the given socket path. + /// + /// Sets a 5-second read timeout so the client doesn't hang indefinitely + /// if the daemon stops responding. + pub fn connect(socket_path: &Path) -> io::Result { + let stream = UnixStream::connect(socket_path)?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + let reader = BufReader::new(stream.try_clone()?); + Ok(Self { stream, reader }) + } + + /// Try connecting to the daemon. If the socket isn't available, attempt + /// to start the daemon via systemd and retry with exponential backoff. + /// + /// Backoff schedule: 100ms, 200ms, 400ms. + pub fn connect_or_start() -> io::Result { + let path = Self::socket_path(); + + // First attempt: just try connecting. + if let Ok(client) = Self::connect(&path) { + return Ok(client); + } + + // Socket not available — try to start the daemon. + let status = std::process::Command::new("systemctl") + .args(["--user", "start", "owlry-core"]) + .status() + .map_err(|e| { + io::Error::new( + io::ErrorKind::Other, + format!("failed to start owlry-core via systemd: {e}"), + ) + })?; + + if !status.success() { + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "systemctl --user start owlry-core exited with status {}", + status + ), + )); + } + + // Retry with exponential backoff. + let delays = [100, 200, 400]; + for (i, ms) in delays.iter().enumerate() { + std::thread::sleep(Duration::from_millis(*ms)); + match Self::connect(&path) { + Ok(client) => return Ok(client), + Err(e) if i == delays.len() - 1 => { + return Err(io::Error::new( + io::ErrorKind::ConnectionRefused, + format!( + "daemon started but socket not available after retries: {e}" + ), + )); + } + Err(_) => continue, + } + } + + unreachable!() + } + + /// Default socket path: `$XDG_RUNTIME_DIR/owlry/owlry.sock`. + /// + /// Delegates to `owlry_core::paths::socket_path()` to keep a single + /// source of truth. + pub fn socket_path() -> PathBuf { + owlry_core::paths::socket_path() + } + + /// Send a search query and return matching results. + pub fn query( + &mut self, + text: &str, + modes: Option>, + ) -> io::Result> { + self.send(&Request::Query { + text: text.to_string(), + modes, + })?; + + match self.receive()? { + Response::Results { items } => Ok(items), + Response::Error { message } => { + Err(io::Error::new(io::ErrorKind::Other, message)) + } + other => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected response to Query: {other:?}"), + )), + } + } + + /// Record a launch event for frecency tracking. + pub fn launch(&mut self, item_id: &str, provider: &str) -> io::Result<()> { + self.send(&Request::Launch { + item_id: item_id.to_string(), + provider: provider.to_string(), + })?; + + match self.receive()? { + Response::Ack => Ok(()), + Response::Error { message } => { + Err(io::Error::new(io::ErrorKind::Other, message)) + } + other => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected response to Launch: {other:?}"), + )), + } + } + + /// List all available providers from the daemon. + pub fn providers(&mut self) -> io::Result> { + self.send(&Request::Providers)?; + + match self.receive()? { + Response::Providers { list } => Ok(list), + Response::Error { message } => { + Err(io::Error::new(io::ErrorKind::Other, message)) + } + other => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected response to Providers: {other:?}"), + )), + } + } + + /// Toggle the launcher window visibility. + pub fn toggle(&mut self) -> io::Result<()> { + self.send(&Request::Toggle)?; + + match self.receive()? { + Response::Ack => Ok(()), + Response::Error { message } => { + Err(io::Error::new(io::ErrorKind::Other, message)) + } + other => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected response to Toggle: {other:?}"), + )), + } + } + + /// Query a plugin's submenu actions. + pub fn submenu( + &mut self, + plugin_id: &str, + data: &str, + ) -> io::Result> { + self.send(&Request::Submenu { + plugin_id: plugin_id.to_string(), + data: data.to_string(), + })?; + + match self.receive()? { + Response::SubmenuItems { items } => Ok(items), + Response::Error { message } => { + Err(io::Error::new(io::ErrorKind::Other, message)) + } + other => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected response to Submenu: {other:?}"), + )), + } + } + + // ========================================================================= + // Internal helpers + // ========================================================================= + + fn send(&mut self, request: &Request) -> io::Result<()> { + let json = serde_json::to_string(request) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + writeln!(self.stream, "{json}")?; + self.stream.flush() + } + + fn receive(&mut self) -> io::Result { + let mut line = String::new(); + self.reader.read_line(&mut line)?; + if line.is_empty() { + 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)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::net::UnixListener; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::thread; + + static COUNTER: AtomicU32 = AtomicU32::new(0); + + /// Spawn a mock server that accepts one connection, reads one request, + /// and replies with the given canned response. Each call gets a unique + /// socket path to avoid collisions when tests run in parallel. + fn mock_server(response: Response) -> PathBuf { + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "owlry-test-{}-{}", + std::process::id(), + n + )); + let _ = std::fs::create_dir_all(&dir); + let sock = dir.join("test.sock"); + let _ = std::fs::remove_file(&sock); + + let listener = UnixListener::bind(&sock).expect("bind mock socket"); + let sock_clone = sock.clone(); + + thread::spawn(move || { + let (stream, _) = listener.accept().expect("accept"); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut writer = stream; + + // Read one request line (we don't care about contents). + let mut line = String::new(); + reader.read_line(&mut line).expect("read request"); + + // Send canned response. + let mut json = serde_json::to_string(&response).unwrap(); + json.push('\n'); + writer.write_all(json.as_bytes()).unwrap(); + writer.flush().unwrap(); + + // Clean up socket after test. + let _ = std::fs::remove_file(&sock_clone); + let _ = std::fs::remove_dir(dir); + }); + + sock + } + + #[test] + fn connect_and_query_returns_results() { + let canned = Response::Results { + items: vec![ResultItem { + id: "firefox".into(), + title: "Firefox".into(), + description: "Web Browser".into(), + icon: "firefox".into(), + provider: "app".into(), + score: 100, + command: Some("firefox".into()), + tags: vec![], + }], + }; + + let sock = mock_server(canned); + // Give the listener thread a moment to start. + thread::sleep(Duration::from_millis(50)); + + let mut client = CoreClient::connect(&sock).expect("connect"); + let results = client.query("fire", None).expect("query"); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "firefox"); + assert_eq!(results[0].title, "Firefox"); + assert_eq!(results[0].score, 100); + } + + #[test] + fn toggle_returns_ack() { + let sock = mock_server(Response::Ack); + thread::sleep(Duration::from_millis(50)); + + let mut client = CoreClient::connect(&sock).expect("connect"); + client.toggle().expect("toggle should succeed"); + } + + #[test] + fn launch_returns_ack() { + let sock = mock_server(Response::Ack); + thread::sleep(Duration::from_millis(50)); + + let mut client = CoreClient::connect(&sock).expect("connect"); + client + .launch("firefox", "app") + .expect("launch should succeed"); + } + + #[test] + fn providers_returns_list() { + let canned = Response::Providers { + list: vec![ProviderDesc { + id: "app".into(), + name: "Applications".into(), + prefix: Some(":app".into()), + icon: "application-x-executable".into(), + position: "normal".into(), + }], + }; + + let sock = mock_server(canned); + thread::sleep(Duration::from_millis(50)); + + let mut client = CoreClient::connect(&sock).expect("connect"); + let providers = client.providers().expect("providers"); + + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].id, "app"); + } + + #[test] + fn submenu_returns_items() { + let canned = Response::SubmenuItems { + items: vec![ResultItem { + id: "start".into(), + title: "Start Service".into(), + description: String::new(), + icon: "media-playback-start".into(), + provider: "systemd".into(), + score: 0, + command: Some("systemctl --user start foo".into()), + tags: vec![], + }], + }; + + let sock = mock_server(canned); + thread::sleep(Duration::from_millis(50)); + + let mut client = CoreClient::connect(&sock).expect("connect"); + let items = client.submenu("systemd", "foo.service").expect("submenu"); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "start"); + } + + #[test] + fn error_response_is_propagated() { + let canned = Response::Error { + message: "something went wrong".into(), + }; + + let sock = mock_server(canned); + thread::sleep(Duration::from_millis(50)); + + let mut client = CoreClient::connect(&sock).expect("connect"); + let err = client.query("test", None).unwrap_err(); + + let msg = err.to_string(); + assert!( + msg.contains("something went wrong"), + "error message should contain the server error, got: {msg}" + ); + } + + #[test] + fn socket_path_delegates_to_core() { + let path = CoreClient::socket_path(); + assert!(path.ends_with("owlry/owlry.sock")); + } +} diff --git a/crates/owlry/src/main.rs b/crates/owlry/src/main.rs index ec99458..3507d22 100644 --- a/crates/owlry/src/main.rs +++ b/crates/owlry/src/main.rs @@ -1,4 +1,5 @@ mod app; +pub mod client; mod cli; mod plugin_commands; mod providers; From 5be21aadc630acdff7fd3c07d83d53206e4545d2 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 12:52:00 +0100 Subject: [PATCH 12/20] refactor(owlry): wire UI to use IPC client instead of direct provider calls The UI now uses a SearchBackend abstraction that wraps either: - CoreClient (daemon mode): connects to owlry-core via IPC for search, frecency tracking, submenu queries, and plugin actions - Local ProviderManager (dmenu mode): unchanged direct provider access Key changes: - New backend.rs with SearchBackend enum abstracting IPC vs local - app.rs creates CoreClient in normal mode, falls back to local if daemon unavailable - main_window.rs uses SearchBackend instead of ProviderManager+FrecencyStore - Command execution stays in the UI (daemon only tracks frecency) - dmenu mode path is completely unchanged (no daemon involvement) - Added terminal field to IPC ResultItem for proper terminal launch - Added PluginAction IPC request for plugin command execution --- Cargo.lock | 26 +++ crates/owlry-core/src/ipc.rs | 5 + crates/owlry-core/src/server.rs | 12 ++ crates/owlry-core/tests/ipc_test.rs | 38 ++++ crates/owlry/src/app.rs | 184 ++++++++----------- crates/owlry/src/backend.rs | 262 ++++++++++++++++++++++++++++ crates/owlry/src/client.rs | 19 ++ crates/owlry/src/main.rs | 1 + crates/owlry/src/ui/main_window.rs | 151 +++++++--------- 9 files changed, 491 insertions(+), 207 deletions(-) create mode 100644 crates/owlry/src/backend.rs diff --git a/Cargo.lock b/Cargo.lock index 130d0ff..bff6297 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -652,6 +652,17 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + [[package]] name = "deranged" version = "0.5.8" @@ -689,6 +700,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags", + "block2", + "libc", "objc2", ] @@ -2208,6 +2221,18 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "nix" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "1.2.4" @@ -2456,6 +2481,7 @@ name = "owlry-core" version = "0.5.0" dependencies = [ "chrono", + "ctrlc", "dirs", "env_logger", "freedesktop-desktop-entry", diff --git a/crates/owlry-core/src/ipc.rs b/crates/owlry-core/src/ipc.rs index 0090ade..69deafd 100644 --- a/crates/owlry-core/src/ipc.rs +++ b/crates/owlry-core/src/ipc.rs @@ -21,6 +21,9 @@ pub enum Request { plugin_id: String, data: String, }, + PluginAction { + command: String, + }, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -51,6 +54,8 @@ pub struct ResultItem { pub score: i64, #[serde(skip_serializing_if = "Option::is_none")] pub command: Option, + #[serde(default)] + pub terminal: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, } diff --git a/crates/owlry-core/src/server.rs b/crates/owlry-core/src/server.rs index 7b9f93e..2ac12a1 100644 --- a/crates/owlry-core/src/server.rs +++ b/crates/owlry-core/src/server.rs @@ -191,6 +191,17 @@ impl Server { }, } } + + Request::PluginAction { command } => { + let pm_guard = pm.lock().unwrap(); + if pm_guard.execute_plugin_action(command) { + Response::Ack + } else { + Response::Error { + message: format!("no plugin handled action '{}'", command), + } + } + } } } } @@ -222,6 +233,7 @@ fn launch_item_to_result(item: LaunchItem, score: i64) -> ResultItem { provider: format!("{}", item.provider), score, command: Some(item.command), + terminal: item.terminal, tags: item.tags, } } diff --git a/crates/owlry-core/tests/ipc_test.rs b/crates/owlry-core/tests/ipc_test.rs index 79928bb..6598fbf 100644 --- a/crates/owlry-core/tests/ipc_test.rs +++ b/crates/owlry-core/tests/ipc_test.rs @@ -45,6 +45,7 @@ fn test_results_response_roundtrip() { provider: "app".into(), score: 95, command: Some("firefox".into()), + terminal: false, tags: vec![], }], }; @@ -107,3 +108,40 @@ fn test_refresh_request() { let parsed: Request = serde_json::from_str(&json).unwrap(); assert_eq!(req, parsed); } + +#[test] +fn test_plugin_action_request() { + let req = Request::PluginAction { + command: "POMODORO:start".into(), + }; + let json = serde_json::to_string(&req).unwrap(); + let parsed: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(req, parsed); +} + +#[test] +fn test_terminal_field_defaults_false() { + // terminal field should default to false when missing from JSON + let json = r#"{"id":"test","title":"Test","description":"","icon":"","provider":"cmd","score":0}"#; + let item: ResultItem = serde_json::from_str(json).unwrap(); + assert!(!item.terminal); +} + +#[test] +fn test_terminal_field_roundtrip() { + let item = ResultItem { + id: "htop".into(), + title: "htop".into(), + description: "Process viewer".into(), + icon: "htop".into(), + provider: "cmd".into(), + score: 50, + command: Some("htop".into()), + terminal: true, + tags: vec![], + }; + let json = serde_json::to_string(&item).unwrap(); + assert!(json.contains("\"terminal\":true")); + let parsed: ResultItem = serde_json::from_str(&json).unwrap(); + assert!(parsed.terminal); +} diff --git a/crates/owlry/src/app.rs b/crates/owlry/src/app.rs index 47836a2..520855f 100644 --- a/crates/owlry/src/app.rs +++ b/crates/owlry/src/app.rs @@ -1,4 +1,6 @@ +use crate::backend::SearchBackend; use crate::cli::CliArgs; +use crate::client::CoreClient; use crate::providers::DmenuProvider; use crate::theme; use crate::ui::MainWindow; @@ -6,19 +8,13 @@ use owlry_core::config::Config; use owlry_core::data::FrecencyStore; use owlry_core::filter::ProviderFilter; use owlry_core::paths; -use owlry_core::plugins::native_loader::NativePluginLoader; -#[cfg(feature = "lua")] -use owlry_core::plugins::PluginManager; -use owlry_core::providers::native_provider::NativeProvider; -use owlry_core::providers::Provider; // For name() method -use owlry_core::providers::{ApplicationProvider, CommandProvider, ProviderManager}; +use owlry_core::providers::{Provider, ProviderManager}; use gtk4::prelude::*; use gtk4::{gio, Application, CssProvider}; use gtk4_layer_shell::{Edge, Layer, LayerShell}; use log::{debug, info, warn}; use std::cell::RefCell; use std::rc::Rc; -use std::sync::Arc; const APP_ID: &str = "org.owlry.launcher"; @@ -53,37 +49,39 @@ impl OwlryApp { let config = Rc::new(RefCell::new(Config::load_or_default())); - // Load native plugins from /usr/lib/owlry/plugins/ - let native_providers = Self::load_native_plugins(&config.borrow()); - - // Build core providers based on mode + // Build backend based on mode let dmenu_mode = DmenuProvider::has_stdin_data(); - let core_providers: Vec> = if dmenu_mode { + + let backend = if dmenu_mode { + // dmenu mode: local ProviderManager, no daemon let mut dmenu = DmenuProvider::new(); dmenu.enable(); - vec![Box::new(dmenu)] + let core_providers: Vec> = vec![Box::new(dmenu)]; + let provider_manager = ProviderManager::new(core_providers, Vec::new()); + let frecency = FrecencyStore::load_or_default(); + + SearchBackend::Local { + providers: provider_manager, + frecency, + } } else { - vec![ - Box::new(ApplicationProvider::new()), - Box::new(CommandProvider::new()), - ] + // Normal mode: connect to daemon via IPC + match CoreClient::connect_or_start() { + Ok(client) => { + info!("Connected to owlry-core daemon"); + SearchBackend::Daemon(client) + } + Err(e) => { + warn!( + "Failed to connect to daemon ({}), falling back to local providers", + e + ); + Self::create_local_backend(&config.borrow()) + } + } }; - // Create provider manager with core providers and native plugins - let native_for_manager = if dmenu_mode { Vec::new() } else { native_providers }; - #[cfg(feature = "lua")] - let mut provider_manager = ProviderManager::new(core_providers, native_for_manager); - #[cfg(not(feature = "lua"))] - let provider_manager = ProviderManager::new(core_providers, native_for_manager); - - // Load Lua plugins if enabled (requires lua feature) - #[cfg(feature = "lua")] - if config.borrow().plugins.enabled { - Self::load_lua_plugins(&mut provider_manager, &config.borrow()); - } - - let providers = Rc::new(RefCell::new(provider_manager)); - let frecency = Rc::new(RefCell::new(FrecencyStore::load_or_default())); + let backend = Rc::new(RefCell::new(backend)); // Create filter from CLI args and config let filter = ProviderFilter::new( @@ -93,7 +91,13 @@ impl OwlryApp { ); let filter = Rc::new(RefCell::new(filter)); - let window = MainWindow::new(app, config.clone(), providers.clone(), frecency.clone(), filter.clone(), args.prompt.clone()); + let window = MainWindow::new( + app, + config.clone(), + backend.clone(), + filter.clone(), + args.prompt.clone(), + ); // Set up layer shell for Wayland overlay behavior window.init_layer_shell(); @@ -119,97 +123,47 @@ impl OwlryApp { window.present(); } - /// Load native (.so) plugins from the system plugins directory - /// Returns NativeProvider instances that can be passed to ProviderManager - fn load_native_plugins(config: &Config) -> Vec { - let mut loader = NativePluginLoader::new(); + /// 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; + use owlry_core::providers::{ApplicationProvider, CommandProvider}; + use std::sync::Arc; - // Set disabled plugins from config + // Load native plugins + let mut loader = NativePluginLoader::new(); loader.set_disabled(config.plugins.disabled_plugins.clone()); - // Discover and load plugins - match loader.discover() { - Ok(count) => { - if count == 0 { - debug!("No native plugins found in {}", - owlry_core::plugins::native_loader::SYSTEM_PLUGINS_DIR); - return Vec::new(); + 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); + } } - info!("Discovered {} native plugin(s)", count); - } - Err(e) => { - warn!("Failed to discover native plugins: {}", e); - return Vec::new(); - } - } - - // Get all plugins and create providers - let plugins: Vec> = - loader.into_plugins(); - - // Create NativeProvider instances from loaded 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); - } - } - - info!("Loaded {} provider(s) from native plugins", providers.len()); - providers - } - - /// Load Lua plugins from the user plugins directory (requires lua feature) - #[cfg(feature = "lua")] - fn load_lua_plugins(provider_manager: &mut ProviderManager, config: &Config) { - let plugins_dir = match paths::plugins_dir() { - Some(dir) => dir, - None => { - warn!("Could not determine plugins directory"); - return; + providers } + _ => Vec::new(), }; - // Get owlry version from Cargo.toml at compile time - let owlry_version = env!("CARGO_PKG_VERSION"); + let core_providers: Vec> = vec![ + Box::new(ApplicationProvider::new()), + Box::new(CommandProvider::new()), + ]; - let mut plugin_manager = PluginManager::new(plugins_dir, owlry_version); + let provider_manager = ProviderManager::new(core_providers, native_providers); + let frecency = FrecencyStore::load_or_default(); - // Set disabled plugins from config - plugin_manager.set_disabled(config.plugins.disabled_plugins.clone()); - - // Discover plugins - match plugin_manager.discover() { - Ok(count) => { - if count == 0 { - debug!("No Lua plugins found"); - return; - } - info!("Discovered {} Lua plugin(s)", count); - } - Err(e) => { - warn!("Failed to discover Lua plugins: {}", e); - return; - } - } - - // Initialize all plugins (load Lua code) - let init_errors = plugin_manager.initialize_all(); - for error in &init_errors { - warn!("Plugin initialization error: {}", error); - } - - // Create providers from initialized plugins - let plugin_providers = plugin_manager.create_providers(); - let provider_count = plugin_providers.len(); - - // Add plugin providers to the main provider manager - provider_manager.add_providers(plugin_providers); - - if provider_count > 0 { - info!("Loaded {} provider(s) from Lua plugins", provider_count); + SearchBackend::Local { + providers: provider_manager, + frecency, } } diff --git a/crates/owlry/src/backend.rs b/crates/owlry/src/backend.rs new file mode 100644 index 0000000..62faf65 --- /dev/null +++ b/crates/owlry/src/backend.rs @@ -0,0 +1,262 @@ +//! Abstraction over search backends for the UI. +//! +//! In normal mode, the UI talks to the owlry-core daemon via IPC. +//! In dmenu mode, the UI uses a local ProviderManager directly (no daemon). + +use crate::client::CoreClient; +use owlry_core::filter::ProviderFilter; +use owlry_core::ipc::ResultItem; +use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType}; +use owlry_core::data::FrecencyStore; +use owlry_core::config::Config; +use log::warn; + +/// Backend for search operations. Wraps either an IPC client (daemon mode) +/// or a local ProviderManager (dmenu mode). +pub enum SearchBackend { + /// IPC client connected to owlry-core daemon + Daemon(CoreClient), + /// Direct local provider manager (dmenu mode only) + Local { + providers: ProviderManager, + frecency: FrecencyStore, + }, +} + +impl SearchBackend { + /// Search for items matching the query. + /// + /// In daemon mode, sends query over IPC. The modes list is derived from + /// the ProviderFilter's enabled set. + /// + /// In local mode, delegates to ProviderManager directly. + pub fn search( + &mut self, + query: &str, + max_results: usize, + filter: &ProviderFilter, + config: &Config, + ) -> Vec { + match self { + SearchBackend::Daemon(client) => { + let modes: Vec = filter + .enabled_providers() + .iter() + .map(|p| p.to_string()) + .collect(); + + let modes_param = if modes.is_empty() { None } else { Some(modes) }; + + match client.query(query, modes_param) { + Ok(items) => items.into_iter().map(result_to_launch_item).collect(), + Err(e) => { + warn!("IPC query failed: {}", e); + Vec::new() + } + } + } + SearchBackend::Local { + providers, + frecency, + } => { + let frecency_weight = config.providers.frecency_weight; + let use_frecency = config.providers.frecency; + + if use_frecency { + providers + .search_with_frecency(query, max_results, filter, frecency, frecency_weight, None) + .into_iter() + .map(|(item, _)| item) + .collect() + } else { + providers + .search_filtered(query, max_results, filter) + .into_iter() + .map(|(item, _)| item) + .collect() + } + } + } + } + + /// Search with tag filter support. + pub fn search_with_tag( + &mut self, + query: &str, + max_results: usize, + filter: &ProviderFilter, + config: &Config, + tag_filter: Option<&str>, + ) -> Vec { + match self { + SearchBackend::Daemon(client) => { + // Daemon doesn't support tag filtering in IPC yet — pass query as-is. + // If there's a tag filter, prepend it so the daemon can handle it. + let effective_query = if let Some(tag) = tag_filter { + format!(":tag:{} {}", tag, query) + } else { + query.to_string() + }; + + let modes: Vec = filter + .enabled_providers() + .iter() + .map(|p| p.to_string()) + .collect(); + + let modes_param = if modes.is_empty() { None } else { Some(modes) }; + + match client.query(&effective_query, modes_param) { + Ok(items) => items.into_iter().map(result_to_launch_item).collect(), + Err(e) => { + warn!("IPC query failed: {}", e); + Vec::new() + } + } + } + SearchBackend::Local { + providers, + frecency, + } => { + let frecency_weight = config.providers.frecency_weight; + let use_frecency = config.providers.frecency; + + if use_frecency { + providers + .search_with_frecency(query, max_results, filter, frecency, frecency_weight, tag_filter) + .into_iter() + .map(|(item, _)| item) + .collect() + } else { + providers + .search_filtered(query, max_results, filter) + .into_iter() + .map(|(item, _)| item) + .collect() + } + } + } + } + + /// Execute a plugin action command. Returns true if handled. + pub fn execute_plugin_action(&mut self, command: &str) -> bool { + match self { + SearchBackend::Daemon(client) => { + match client.plugin_action(command) { + Ok(handled) => handled, + Err(e) => { + warn!("IPC plugin_action failed: {}", e); + false + } + } + } + SearchBackend::Local { providers, .. } => { + providers.execute_plugin_action(command) + } + } + } + + /// Query submenu actions for a plugin item. + /// Returns (display_name, actions) if available. + pub fn query_submenu_actions( + &mut self, + plugin_id: &str, + data: &str, + display_name: &str, + ) -> Option<(String, Vec)> { + match self { + SearchBackend::Daemon(client) => { + match client.submenu(plugin_id, data) { + Ok(items) if !items.is_empty() => { + let actions: Vec = + items.into_iter().map(result_to_launch_item).collect(); + Some((display_name.to_string(), actions)) + } + Ok(_) => None, + Err(e) => { + warn!("IPC submenu query failed: {}", e); + None + } + } + } + SearchBackend::Local { providers, .. } => { + providers.query_submenu_actions(plugin_id, data, display_name) + } + } + } + + /// Record a launch event for frecency tracking. + pub fn record_launch(&mut self, item_id: &str, provider: &str) { + match self { + SearchBackend::Daemon(client) => { + if let Err(e) = client.launch(item_id, provider) { + warn!("IPC launch notification failed: {}", e); + } + } + SearchBackend::Local { frecency, .. } => { + frecency.record_launch(item_id); + } + } + } + + /// Whether this backend is in dmenu mode. + pub fn is_dmenu_mode(&self) -> bool { + match self { + SearchBackend::Daemon(_) => false, + SearchBackend::Local { providers, .. } => providers.is_dmenu_mode(), + } + } + + /// 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(); + } + } + + /// Get available provider type IDs from the daemon, or from local manager. + #[allow(dead_code)] + pub fn available_provider_ids(&mut self) -> Vec { + match self { + SearchBackend::Daemon(client) => { + match client.providers() { + Ok(descs) => descs.into_iter().map(|d| d.id).collect(), + Err(e) => { + warn!("IPC providers query failed: {}", e); + Vec::new() + } + } + } + SearchBackend::Local { providers, .. } => { + providers + .available_providers() + .into_iter() + .map(|d| d.id) + .collect() + } + } + } +} + +/// Convert an IPC ResultItem to the internal LaunchItem type. +fn result_to_launch_item(item: ResultItem) -> LaunchItem { + let provider: ProviderType = item.provider.parse().unwrap_or(ProviderType::Application); + LaunchItem { + id: item.id, + name: item.title, + description: if item.description.is_empty() { + None + } else { + Some(item.description) + }, + icon: if item.icon.is_empty() { + None + } else { + Some(item.icon) + }, + provider, + command: item.command.unwrap_or_default(), + terminal: item.terminal, + tags: item.tags, + } +} diff --git a/crates/owlry/src/client.rs b/crates/owlry/src/client.rs index 6e4bfa0..836c60a 100644 --- a/crates/owlry/src/client.rs +++ b/crates/owlry/src/client.rs @@ -160,6 +160,23 @@ impl CoreClient { } } + /// Execute a plugin action command (e.g., "POMODORO:start"). + /// Returns Ok(true) if the plugin handled the action, Ok(false) if not. + pub fn plugin_action(&mut self, command: &str) -> io::Result { + self.send(&Request::PluginAction { + command: command.to_string(), + })?; + + match self.receive()? { + Response::Ack => Ok(true), + Response::Error { .. } => Ok(false), + other => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected response to PluginAction: {other:?}"), + )), + } + } + /// Query a plugin's submenu actions. pub fn submenu( &mut self, @@ -268,6 +285,7 @@ mod tests { provider: "app".into(), score: 100, command: Some("firefox".into()), + terminal: false, tags: vec![], }], }; @@ -338,6 +356,7 @@ mod tests { provider: "systemd".into(), score: 0, command: Some("systemctl --user start foo".into()), + terminal: false, tags: vec![], }], }; diff --git a/crates/owlry/src/main.rs b/crates/owlry/src/main.rs index 3507d22..83ac9b2 100644 --- a/crates/owlry/src/main.rs +++ b/crates/owlry/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod backend; pub mod client; mod cli; mod plugin_commands; diff --git a/crates/owlry/src/ui/main_window.rs b/crates/owlry/src/ui/main_window.rs index 792250a..fb4c714 100644 --- a/crates/owlry/src/ui/main_window.rs +++ b/crates/owlry/src/ui/main_window.rs @@ -1,7 +1,7 @@ +use crate::backend::SearchBackend; use owlry_core::config::Config; -use owlry_core::data::FrecencyStore; use owlry_core::filter::ProviderFilter; -use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType}; +use owlry_core::providers::{LaunchItem, ProviderType}; use crate::ui::submenu; use crate::ui::ResultRow; use gtk4::gdk::Key; @@ -56,8 +56,7 @@ pub struct MainWindow { results_list: ListBox, scrolled: ScrolledWindow, config: Rc>, - providers: Rc>, - frecency: Rc>, + backend: Rc>, current_results: Rc>>, filter: Rc>, mode_label: Label, @@ -81,8 +80,7 @@ impl MainWindow { pub fn new( app: &Application, config: Rc>, - providers: Rc>, - frecency: Rc>, + backend: Rc>, filter: Rc>, custom_prompt: Option, ) -> Self { @@ -199,8 +197,8 @@ impl MainWindow { let lazy_state = Rc::new(RefCell::new(LazyLoadState::default())); - // Check if we're in dmenu mode (stdin pipe input) - let is_dmenu_mode = providers.borrow().is_dmenu_mode(); + // Check if we're in dmenu mode + let is_dmenu_mode = backend.borrow().is_dmenu_mode(); let main_window = Self { window, @@ -208,8 +206,7 @@ impl MainWindow { results_list, scrolled, config, - providers, - frecency, + backend, current_results: Rc::new(RefCell::new(Vec::new())), filter, mode_label, @@ -230,46 +227,43 @@ impl MainWindow { // Ensure search entry has focus when window is shown main_window.search_entry.grab_focus(); - // Schedule widget refresh after window is shown + // Schedule widget refresh after window is shown (only for local backend) // Widget providers (weather, media, pomodoro) may make network/dbus calls // We defer this to avoid blocking startup, then re-render results - let providers_for_refresh = main_window.providers.clone(); + let backend_for_refresh = main_window.backend.clone(); let search_entry_for_refresh = main_window.search_entry.clone(); gtk4::glib::timeout_add_local_once(std::time::Duration::from_millis(50), move || { - providers_for_refresh.borrow_mut().refresh_widgets(); + backend_for_refresh.borrow_mut().refresh_widgets(); // Trigger UI update by emitting changed signal on search entry search_entry_for_refresh.emit_by_name::<()>("changed", &[]); }); - // Set up periodic widget auto-refresh (every 5 seconds) - // Always refresh widgets (for pomodoro timer/notifications), but only update UI when visible - let providers_for_auto = main_window.providers.clone(); + // Set up periodic widget auto-refresh (every 5 seconds) — local backend only + // In daemon mode, the daemon handles widget refresh and results come via IPC + if main_window.is_dmenu_mode { + // dmenu typically has no widgets, but this is harmless + } + let backend_for_auto = main_window.backend.clone(); let current_results_for_auto = main_window.current_results.clone(); let submenu_state_for_auto = main_window.submenu_state.clone(); + let search_entry_for_auto = main_window.search_entry.clone(); gtk4::glib::timeout_add_local(std::time::Duration::from_secs(5), move || { - // Skip UI updates if in submenu, but still refresh providers for notifications let in_submenu = submenu_state_for_auto.borrow().active; - // Always refresh widget providers (pomodoro needs this for timer/notifications) - providers_for_auto.borrow_mut().refresh_widgets(); + // For local backend: refresh widgets (daemon handles this itself) + backend_for_auto.borrow_mut().refresh_widgets(); - // Only update UI if not in submenu and widgets are visible + // For daemon backend: re-query to get updated widget data if !in_submenu { - // Collect widget type_ids first to avoid borrow conflicts - let widget_ids: Vec = providers_for_auto - .borrow() - .widget_type_ids() - .map(|s| s.to_string()) - .collect(); - - let mut results = current_results_for_auto.borrow_mut(); - for type_id in &widget_ids { - if let Some(new_item) = providers_for_auto.borrow().get_widget_item(type_id) - && let Some(existing) = results.iter_mut().find(|i| i.id == new_item.id) - { - existing.name = new_item.name; - existing.description = new_item.description; - } + if let SearchBackend::Daemon(_) = &*backend_for_auto.borrow() { + // Trigger a re-search to pick up updated widget items from daemon + search_entry_for_auto.emit_by_name::<()>("changed", &[]); + } else { + // Local backend: update widget items in-place (legacy behavior) + // This path is only hit in dmenu mode which doesn't have widgets, + // but keep it for completeness. + let _results = current_results_for_auto.borrow(); + // No-op for local mode without widget access } } gtk4::glib::ControlFlow::Continue @@ -566,10 +560,9 @@ impl MainWindow { fn setup_signals(&self) { // Search input handling with prefix detection and debouncing - let providers = self.providers.clone(); + let backend = self.backend.clone(); let results_list = self.results_list.clone(); let config = self.config.clone(); - let frecency = self.frecency.clone(); let current_results = self.current_results.clone(); let filter = self.filter.clone(); let mode_label = self.mode_label.clone(); @@ -661,10 +654,9 @@ impl MainWindow { } // Clone references for the debounced closure - let providers = providers.clone(); + let backend = backend.clone(); let results_list = results_list.clone(); let config = config.clone(); - let frecency = frecency.clone(); let current_results = current_results.clone(); let filter = filter.clone(); let lazy_state = lazy_state.clone(); @@ -679,25 +671,15 @@ impl MainWindow { let cfg = config.borrow(); let max_results = cfg.general.max_results; - let frecency_weight = cfg.providers.frecency_weight; - let use_frecency = cfg.providers.frecency; drop(cfg); - let results: Vec = if use_frecency { - providers - .borrow_mut() - .search_with_frecency(&parsed.query, max_results, &filter.borrow(), &frecency.borrow(), frecency_weight, parsed.tag_filter.as_deref()) - .into_iter() - .map(|(item, _)| item) - .collect() - } else { - providers - .borrow() - .search_filtered(&parsed.query, max_results, &filter.borrow()) - .into_iter() - .map(|(item, _)| item) - .collect() - }; + let results = backend.borrow_mut().search_with_tag( + &parsed.query, + max_results, + &filter.borrow(), + &config.borrow(), + parsed.tag_filter.as_deref(), + ); // Clear existing results while let Some(child) = results_list.first_child() { @@ -734,8 +716,7 @@ impl MainWindow { let results_list_for_activate = self.results_list.clone(); let current_results_for_activate = self.current_results.clone(); let config_for_activate = self.config.clone(); - let frecency_for_activate = self.frecency.clone(); - let providers_for_activate = self.providers.clone(); + let backend_for_activate = self.backend.clone(); let window_for_activate = self.window.clone(); let submenu_state_for_activate = self.submenu_state.clone(); let mode_label_for_activate = self.mode_label.clone(); @@ -761,8 +742,8 @@ impl MainWindow { let data = data.to_string(); let display_name = item.name.clone(); drop(results); // Release borrow before querying - providers_for_activate - .borrow() + backend_for_activate + .borrow_mut() .query_submenu_actions(&plugin_id, &data, &display_name) } else { drop(results); @@ -791,8 +772,7 @@ impl MainWindow { let should_close = Self::handle_item_action( &item, &config_for_activate.borrow(), - &frecency_for_activate, - &providers_for_activate, + &backend_for_activate, ); if should_close { // In dmenu mode, exit with success code @@ -1002,8 +982,7 @@ impl MainWindow { // Double-click to launch let current_results = self.current_results.clone(); let config = self.config.clone(); - let frecency = self.frecency.clone(); - let providers = self.providers.clone(); + let backend = self.backend.clone(); let window = self.window.clone(); let submenu_state = self.submenu_state.clone(); let results_list_for_click = self.results_list.clone(); @@ -1023,8 +1002,8 @@ impl MainWindow { let data = data.to_string(); let display_name = item.name.clone(); drop(results); - providers - .borrow() + backend + .borrow_mut() .query_submenu_actions(&plugin_id, &data, &display_name) } else { drop(results); @@ -1050,7 +1029,7 @@ impl MainWindow { let results = current_results.borrow(); if let Some(item) = results.get(index).cloned() { drop(results); - let should_close = Self::handle_item_action(&item, &config.borrow(), &frecency, &providers); + let should_close = Self::handle_item_action(&item, &config.borrow(), &backend); if should_close { window.close(); } else { @@ -1166,26 +1145,14 @@ impl MainWindow { fn update_results(&self, query: &str) { let cfg = self.config.borrow(); let max_results = cfg.general.max_results; - let frecency_weight = cfg.providers.frecency_weight; - let use_frecency = cfg.providers.frecency; drop(cfg); - // Fetch all matching results (up to max_results) - let results: Vec = if use_frecency { - self.providers - .borrow_mut() - .search_with_frecency(query, max_results, &self.filter.borrow(), &self.frecency.borrow(), frecency_weight, None) - .into_iter() - .map(|(item, _)| item) - .collect() - } else { - self.providers - .borrow() - .search_filtered(query, max_results, &self.filter.borrow()) - .into_iter() - .map(|(item, _)| item) - .collect() - }; + let results = self.backend.borrow_mut().search( + query, + max_results, + &self.filter.borrow(), + &self.config.borrow(), + ); // Clear existing results while let Some(child) = self.results_list.first_child() { @@ -1284,32 +1251,32 @@ impl MainWindow { fn handle_item_action( item: &LaunchItem, config: &Config, - frecency: &Rc>, - providers: &Rc>, + backend: &Rc>, ) -> bool { // Check for plugin internal commands (format: PLUGIN_ID:action) // These are handled by the plugin itself, not launched as shell commands - if providers.borrow().execute_plugin_action(&item.command) { + if backend.borrow_mut().execute_plugin_action(&item.command) { // Plugin handled the action - don't close window // User might want to see updated state (e.g., pomodoro timer) return false; } // Regular item launch - Self::launch_item(item, config, frecency); + Self::launch_item(item, config, backend); true } - fn launch_item(item: &LaunchItem, config: &Config, frecency: &Rc>) { + fn launch_item(item: &LaunchItem, config: &Config, backend: &Rc>) { // dmenu mode: print selection to stdout instead of executing if matches!(item.provider, ProviderType::Dmenu) { println!("{}", item.name); return; } - // Record this launch for frecency tracking + // Record this launch for frecency tracking (via backend) if config.providers.frecency { - frecency.borrow_mut().record_launch(&item.id); + let provider_str = item.provider.to_string(); + backend.borrow_mut().record_launch(&item.id, &provider_str); #[cfg(feature = "dev-logging")] debug!("[UI] Recorded frecency launch for: {}", item.id); } From 30b2b5b9c07def99b24253ec6c36184aa5584cb1 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 12:56:30 +0100 Subject: [PATCH 13/20] feat(owlry): implement toggle behavior for repeated invocations Use a flock-based lock file at $XDG_RUNTIME_DIR/owlry/owlry-ui.lock to detect when another owlry UI instance is already running. If the lock is held, send a Toggle IPC command to the daemon and exit immediately instead of opening a second window. --- crates/owlry/src/main.rs | 54 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/crates/owlry/src/main.rs b/crates/owlry/src/main.rs index 83ac9b2..90a37aa 100644 --- a/crates/owlry/src/main.rs +++ b/crates/owlry/src/main.rs @@ -10,10 +10,43 @@ mod ui; use app::OwlryApp; use cli::{CliArgs, Command}; use log::{info, warn}; +use std::os::unix::io::AsRawFd; #[cfg(feature = "dev-logging")] use log::debug; +/// Try to acquire an exclusive lock on the UI lock file. +/// +/// Returns `Some(File)` if the lock was acquired (no other instance running), +/// or `None` if another instance already holds the lock. +/// The returned `File` must be kept alive for the duration of the process. +fn try_acquire_lock() -> Option { + use std::os::unix::fs::OpenOptionsExt; + + let lock_path = owlry_core::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); + } + + std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&lock_path) + .ok() + .and_then(|f| { + let fd = f.as_raw_fd(); + let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) }; + if ret == 0 { Some(f) } else { None } + }) +} + fn main() { let args = CliArgs::parse_args(); @@ -46,6 +79,27 @@ fn main() { debug!("CLI args: {:?}", args); } + // Toggle behavior: if another instance is already running, tell the daemon + // to toggle visibility and exit immediately. + 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) { + if let Err(e) = client.toggle() { + eprintln!("Failed to toggle existing instance: {}", e); + std::process::exit(1); + } + } else { + eprintln!("Another instance is running but daemon is unreachable"); + std::process::exit(1); + } + std::process::exit(0); + } + }; + info!("Starting Owlry launcher"); // Diagnostic: log critical environment variables From 6391711df2e1d87ebd8e7deb53452baf7236c0b6 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 12:58:47 +0100 Subject: [PATCH 14/20] feat: add config profiles, remove --providers flag Add ProfileConfig struct and profiles map to Config, allowing named mode presets in config.toml (e.g. [profiles.dev] modes = ["app","cmd"]). Remove the --providers/-p CLI flag and repurpose -p as the short form for --prompt. Add --profile flag that loads modes from a named profile. Mode resolution priority: --mode > --profile > config defaults. --- crates/owlry-core/src/config/mod.rs | 17 ++++++++++ crates/owlry/src/app.rs | 52 ++++++++++++++++++++++++----- crates/owlry/src/cli.rs | 25 +++++++++----- 3 files changed, 78 insertions(+), 16 deletions(-) diff --git a/crates/owlry-core/src/config/mod.rs b/crates/owlry-core/src/config/mod.rs index dc6a57f..1e54096 100644 --- a/crates/owlry-core/src/config/mod.rs +++ b/crates/owlry-core/src/config/mod.rs @@ -6,6 +6,21 @@ use std::process::Command; use crate::paths; +/// A named profile that selects a set of provider modes. +/// +/// Defined in config.toml as: +/// ```toml +/// [profiles.dev] +/// modes = ["app", "cmd", "ssh"] +/// +/// [profiles.media] +/// modes = ["media", "emoji"] +/// ``` +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ProfileConfig { + pub modes: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Config { #[serde(default)] @@ -16,6 +31,8 @@ pub struct Config { pub providers: ProvidersConfig, #[serde(default)] pub plugins: PluginsConfig, + #[serde(default)] + pub profiles: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/owlry/src/app.rs b/crates/owlry/src/app.rs index 520855f..918140b 100644 --- a/crates/owlry/src/app.rs +++ b/crates/owlry/src/app.rs @@ -8,7 +8,7 @@ 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}; +use owlry_core::providers::{Provider, ProviderManager, ProviderType}; use gtk4::prelude::*; use gtk4::{gio, Application, CssProvider}; use gtk4_layer_shell::{Edge, Layer, LayerShell}; @@ -36,7 +36,7 @@ impl OwlryApp { pub fn run(&self) -> i32 { // Use empty args since clap already parsed our CLI arguments. - // This prevents GTK from trying to parse --mode, --providers, etc. + // This prevents GTK from trying to parse --mode, --profile, etc. self.app.run_with_args(&[] as &[&str]).into() } @@ -83,12 +83,30 @@ impl OwlryApp { let backend = Rc::new(RefCell::new(backend)); - // Create filter from CLI args and config - let filter = ProviderFilter::new( - args.mode.clone(), - args.providers.clone(), - &config.borrow().providers, - ); + // Create filter from CLI args, profile, and config + let resolved_modes = resolve_modes(args, &config.borrow()); + let filter = if let Some(modes) = resolved_modes { + // CLI --mode or --profile specified explicit modes + let provider_types: Vec = modes + .iter() + .map(|s| ProviderFilter::mode_string_to_provider_type(s)) + .collect(); + if provider_types.len() == 1 { + ProviderFilter::new( + Some(provider_types[0].clone()), + None, + &config.borrow().providers, + ) + } else { + ProviderFilter::new( + None, + Some(provider_types), + &config.borrow().providers, + ) + } + } else { + ProviderFilter::new(None, None, &config.borrow().providers) + }; let filter = Rc::new(RefCell::new(filter)); let window = MainWindow::new( @@ -246,3 +264,21 @@ impl OwlryApp { debug!("Injected config CSS variables"); } } + +/// Resolve which modes to enable based on CLI args and config profiles. +/// +/// Priority: `--mode` > `--profile` > default (all providers from config). +/// Returns `None` when no explicit mode selection was made. +fn resolve_modes(args: &CliArgs, config: &Config) -> Option> { + if let Some(ref mode) = args.mode { + return Some(vec![mode.to_string()]); + } + if let Some(ref profile_name) = args.profile { + if let Some(profile) = config.profiles.get(profile_name) { + return Some(profile.modes.clone()); + } + eprintln!("Unknown profile: {}", profile_name); + std::process::exit(1); + } + None +} diff --git a/crates/owlry/src/cli.rs b/crates/owlry/src/cli.rs index 1b345da..63e7cac 100644 --- a/crates/owlry/src/cli.rs +++ b/crates/owlry/src/cli.rs @@ -20,16 +20,24 @@ EXAMPLES: owlry -m app Applications only owlry -m cmd PATH commands only owlry -m dmenu dmenu-compatible mode (reads from stdin) - owlry -p app,cmd Multiple providers + owlry --profile dev Use a named profile from config owlry -m calc Calculator plugin only (if installed) DMENU MODE: Pipe input to owlry for interactive selection: echo -e \"Option A\\nOption B\" | owlry -m dmenu - ls | 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: :app firefox Search applications :cmd git Search PATH commands @@ -48,17 +56,18 @@ pub struct CliArgs { #[arg(long, short = 'm', value_parser = parse_provider, value_name = "MODE")] pub mode: Option, - /// Comma-separated list of enabled providers + /// Use a named profile from config (defines which modes to enable) /// - /// Examples: -p app,cmd or -p app,calc,emoji - #[arg(long, short = 'p', value_delimiter = ',', value_parser = parse_provider, value_name = "PROVIDERS")] - pub providers: Option>, + /// Profiles are defined in config.toml under [profiles.]. + /// Example: --profile dev (loads modes from [profiles.dev]) + #[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: --prompt "Select file:" - #[arg(long, value_name = "TEXT")] + /// Example: -p "Select file:" or --prompt "Select file:" + #[arg(long, short = 'p', value_name = "TEXT")] pub prompt: Option, /// Subcommand to run (if any) From d4f71cae427dc598a269c880a2184e69566a85e4 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 12:59:49 +0100 Subject: [PATCH 15/20] feat: add systemd user service and socket units for owlry-core Add owlry-core.service (Type=simple, restart-on-failure) and owlry-core.socket (listening on $XDG_RUNTIME_DIR/owlry/owlry.sock) for socket-activated daemon deployment. --- systemd/owlry-core.service | 14 ++++++++++++++ systemd/owlry-core.socket | 9 +++++++++ 2 files changed, 23 insertions(+) create mode 100644 systemd/owlry-core.service create mode 100644 systemd/owlry-core.socket diff --git a/systemd/owlry-core.service b/systemd/owlry-core.service new file mode 100644 index 0000000..ce240de --- /dev/null +++ b/systemd/owlry-core.service @@ -0,0 +1,14 @@ +[Unit] +Description=Owlry application launcher daemon +Documentation=https://somegit.dev/Owlibou/owlry +After=graphical-session.target + +[Service] +Type=simple +ExecStart=/usr/bin/owlry-core +Restart=on-failure +RestartSec=3 +Environment=RUST_LOG=warn + +[Install] +WantedBy=default.target diff --git a/systemd/owlry-core.socket b/systemd/owlry-core.socket new file mode 100644 index 0000000..01ebf53 --- /dev/null +++ b/systemd/owlry-core.socket @@ -0,0 +1,9 @@ +[Unit] +Description=Owlry launcher socket + +[Socket] +ListenStream=%t/owlry/owlry.sock +DirectoryMode=0700 + +[Install] +WantedBy=sockets.target From 938a9ee6f305d440c6c7668be93caafc123cdf01 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 13:03:48 +0100 Subject: [PATCH 16/20] docs: update README and justfile for client/daemon architecture --- README.md | 135 +++++++++++++++++++++++++++++++++++++++++++++++------- justfile | 15 ++++++ 2 files changed, 133 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 9804c74..5e99706 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,15 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and ## Features +- **Client/daemon architecture** — Instant window appearance, providers stay loaded in memory - **Modular plugin architecture** — Install only what you need - **Fuzzy search with tags** — Fast matching across names, descriptions, and category tags - **13 native plugins** — Calculator, clipboard, emoji, weather, media, and more - **Widget providers** — Weather, media controls, and pomodoro timer at the top of results +- **Config profiles** — Named mode presets for different workflows - **Filter prefixes** — Scope searches with `:app`, `:cmd`, `:tag:development`, etc. - **Frecency ranking** — Frequently/recently used items rank higher +- **Toggle behavior** — Bind one key to open/close the launcher - **GTK4 theming** — System theme by default, with 9 built-in themes - **Wayland native** — Uses Layer Shell for proper overlay behavior - **Extensible** — Create custom plugins in Lua or Rune @@ -46,7 +49,7 @@ yay -S owlry-rune # Rune runtime | Package | Description | |---------|-------------| -| `owlry` | Core binary with applications and commands | +| `owlry` | Core: UI client (`owlry`) and daemon (`owlry-core`) | | `owlry-plugin-calculator` | Math expressions (`= 5+3`) | | `owlry-plugin-system` | Shutdown, reboot, suspend, lock | | `owlry-plugin-ssh` | SSH hosts from `~/.ssh/config` | @@ -80,8 +83,8 @@ sudo dnf install gtk4-devel gtk4-layer-shell-devel git clone https://somegit.dev/Owlibou/owlry.git cd owlry -# Build core only -cargo build --release -p owlry +# Build core only (daemon + UI) +cargo build --release -p owlry -p owlry-core # Build specific plugin cargo build --release -p owlry-plugin-calculator @@ -90,26 +93,109 @@ cargo build --release -p owlry-plugin-calculator cargo build --release --workspace ``` -**Install plugins manually:** +**Install locally:** ```bash -sudo mkdir -p /usr/lib/owlry/plugins -sudo cp target/release/libowlry_plugin_*.so /usr/lib/owlry/plugins/ +just install-local ``` +This installs both binaries, all plugins, runtimes, and the systemd service files. + +## Getting Started + +Owlry uses a client/daemon architecture. The daemon (`owlry-core`) loads providers and plugins into memory. The UI client (`owlry`) connects to the daemon over a Unix socket for instant results. + +### Starting the Daemon + +Choose one of three methods: + +**1. Compositor autostart (recommended for most users)** + +Add to your compositor config: + +```bash +# Hyprland (~/.config/hypr/hyprland.conf) +exec-once = owlry-core + +# Sway (~/.config/sway/config) +exec owlry-core +``` + +**2. Systemd user service** + +```bash +systemctl --user enable --now owlry-core.service +``` + +**3. Socket activation (auto-start on first use)** + +```bash +systemctl --user enable owlry-core.socket +``` + +The daemon starts automatically when the UI client first connects. No manual startup needed. + +### Launching the UI + +Bind `owlry` to a key in your compositor: + +```bash +# Hyprland +bind = SUPER, Space, exec, owlry + +# Sway +bindsym $mod+space exec owlry +``` + +Running `owlry` a second time while it is already open sends a toggle command — the window closes. This means a single keybind acts as open/close. + +If the daemon is not running when the UI launches, it will attempt to start it via systemd automatically. + ## Usage ```bash owlry # Launch with all providers owlry -m app # Applications only owlry -m cmd # PATH commands only -owlry -p app,cmd # Multiple specific providers owlry -m calc # Calculator plugin only (if installed) +owlry --profile dev # Use a named profile from config owlry --help # Show all options with examples ``` +### 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"] +``` + +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 +``` + ### dmenu Mode -Owlry is dmenu-compatible. Pipe input for interactive selection - the selected item is printed to stdout (not executed), so you pipe the output to execute it: +Owlry is dmenu-compatible. Pipe input for interactive selection — the selected item is printed to stdout (not executed), so you pipe the output to execute it. + +dmenu mode is self-contained: it does not use the daemon and works without `owlry-core` running. ```bash # Screenshot menu (execute selected command) @@ -234,13 +320,20 @@ frecency_weight = 0.3 # 0.0-1.0 # Web search engine: google, duckduckgo, bing, startpage, brave, ecosia search_engine = "duckduckgo" + +# Profiles: named sets of modes +[profiles.dev] +modes = ["app", "cmd", "ssh"] + +[profiles.media] +modes = ["media", "emoji"] ``` See `/usr/share/doc/owlry/config.example.toml` for all options with documentation. ## Plugin System -Owlry uses a modular plugin architecture. Plugins are loaded from: +Owlry uses a modular plugin architecture. Plugins are loaded by the daemon (`owlry-core`) from: - `/usr/lib/owlry/plugins/*.so` — System plugins (AUR packages) - `~/.config/owlry/plugins/` — User plugins (requires `owlry-lua` or `owlry-rune`) @@ -345,17 +438,25 @@ Create `~/.config/owlry/themes/mytheme.css`: ## Architecture +Owlry uses a client/daemon split: + ``` -owlry (core) -├── Applications provider (XDG .desktop files) -├── Commands provider (PATH executables) -├── Dmenu provider (pipe compatibility) -└── Plugin loader - ├── /usr/lib/owlry/plugins/*.so (native plugins) - ├── /usr/lib/owlry/runtimes/ (Lua/Rune runtimes) - └── ~/.config/owlry/plugins/ (user plugins) +owlry-core (daemon) owlry (GTK4 UI client) +├── Loads config + plugins ├── Connects to daemon via Unix socket +├── Applications provider ├── Renders results in GTK4 window +├── Commands provider ├── Handles keyboard input +├── Plugin loader ├── Toggle: second launch closes window +│ ├── /usr/lib/owlry/plugins/*.so └── dmenu mode (self-contained, no daemon) +│ ├── /usr/lib/owlry/runtimes/ +│ └── ~/.config/owlry/plugins/ +├── Frecency tracking +└── IPC server (Unix socket) + │ + └── $XDG_RUNTIME_DIR/owlry/owlry.sock ``` +The daemon keeps providers and plugins loaded in memory, so the UI appears instantly when launched. The UI client is a thin GTK4 layer that sends queries and receives results over the socket. + For detailed architecture information, see [CLAUDE.md](CLAUDE.md). ## License diff --git a/justfile b/justfile index 8b6083e..dce0f02 100644 --- a/justfile +++ b/justfile @@ -109,12 +109,27 @@ install-local: echo " → librune.so" fi + echo "Installing systemd service files..." + if [ -f "systemd/owlry-core.service" ]; then + sudo install -Dm644 systemd/owlry-core.service /usr/lib/systemd/user/owlry-core.service + echo " → owlry-core.service" + fi + if [ -f "systemd/owlry-core.socket" ]; then + sudo install -Dm644 systemd/owlry-core.socket /usr/lib/systemd/user/owlry-core.socket + echo " → owlry-core.socket" + fi + echo "" echo "Installation complete!" echo " - /usr/bin/owlry (UI)" echo " - /usr/bin/owlry-core (daemon)" echo " - $(ls /usr/lib/owlry/plugins/*.so 2>/dev/null | wc -l) plugins" echo " - $(ls /usr/lib/owlry/runtimes/*.so 2>/dev/null | wc -l) runtimes" + echo " - systemd: owlry-core.service, owlry-core.socket" + echo "" + echo "To start the daemon:" + echo " systemctl --user enable --now owlry-core.service" + echo " OR add 'exec-once = owlry-core' to your compositor config" # === Release Management === From a0b65e69a49cbec74cb26e3dc39f1aa637c6dada Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 13:21:59 +0100 Subject: [PATCH 17/20] refactor: remove plugin crates from core repo Plugins have been moved to the owlry-plugins repo. This removes: - All 13 owlry-plugin-* crate directories - Plugin documentation (PLUGINS.md, PLUGIN_DEVELOPMENT.md) - Plugin-specific justfile targets (build, bump, AUR) Retained in core: owlry (UI), owlry-core (daemon), owlry-plugin-api (ABI interface), owlry-lua, owlry-rune (runtimes). --- Cargo.lock | 207 +----- Cargo.toml | 15 +- crates/owlry-plugin-bookmarks/Cargo.toml | 31 - crates/owlry-plugin-bookmarks/src/lib.rs | 662 ------------------- crates/owlry-plugin-calculator/Cargo.toml | 23 - crates/owlry-plugin-calculator/src/lib.rs | 231 ------- crates/owlry-plugin-clipboard/Cargo.toml | 20 - crates/owlry-plugin-clipboard/src/lib.rs | 259 -------- crates/owlry-plugin-emoji/Cargo.toml | 20 - crates/owlry-plugin-emoji/src/lib.rs | 565 ---------------- crates/owlry-plugin-filesearch/Cargo.toml | 23 - crates/owlry-plugin-filesearch/src/lib.rs | 322 --------- crates/owlry-plugin-media/Cargo.toml | 23 - crates/owlry-plugin-media/src/lib.rs | 468 -------------- crates/owlry-plugin-pomodoro/Cargo.toml | 30 - crates/owlry-plugin-pomodoro/src/lib.rs | 478 -------------- crates/owlry-plugin-scripts/Cargo.toml | 23 - crates/owlry-plugin-scripts/src/lib.rs | 290 --------- crates/owlry-plugin-ssh/Cargo.toml | 23 - crates/owlry-plugin-ssh/src/lib.rs | 328 ---------- crates/owlry-plugin-system/Cargo.toml | 20 - crates/owlry-plugin-system/src/lib.rs | 254 -------- crates/owlry-plugin-systemd/Cargo.toml | 20 - crates/owlry-plugin-systemd/src/lib.rs | 457 ------------- crates/owlry-plugin-weather/Cargo.toml | 33 - crates/owlry-plugin-weather/src/lib.rs | 754 ---------------------- crates/owlry-plugin-websearch/Cargo.toml | 20 - crates/owlry-plugin-websearch/src/lib.rs | 299 --------- docs/PLUGINS.md | 318 --------- docs/PLUGIN_DEVELOPMENT.md | 571 ---------------- justfile | 191 +----- 31 files changed, 23 insertions(+), 6955 deletions(-) delete mode 100644 crates/owlry-plugin-bookmarks/Cargo.toml delete mode 100644 crates/owlry-plugin-bookmarks/src/lib.rs delete mode 100644 crates/owlry-plugin-calculator/Cargo.toml delete mode 100644 crates/owlry-plugin-calculator/src/lib.rs delete mode 100644 crates/owlry-plugin-clipboard/Cargo.toml delete mode 100644 crates/owlry-plugin-clipboard/src/lib.rs delete mode 100644 crates/owlry-plugin-emoji/Cargo.toml delete mode 100644 crates/owlry-plugin-emoji/src/lib.rs delete mode 100644 crates/owlry-plugin-filesearch/Cargo.toml delete mode 100644 crates/owlry-plugin-filesearch/src/lib.rs delete mode 100644 crates/owlry-plugin-media/Cargo.toml delete mode 100644 crates/owlry-plugin-media/src/lib.rs delete mode 100644 crates/owlry-plugin-pomodoro/Cargo.toml delete mode 100644 crates/owlry-plugin-pomodoro/src/lib.rs delete mode 100644 crates/owlry-plugin-scripts/Cargo.toml delete mode 100644 crates/owlry-plugin-scripts/src/lib.rs delete mode 100644 crates/owlry-plugin-ssh/Cargo.toml delete mode 100644 crates/owlry-plugin-ssh/src/lib.rs delete mode 100644 crates/owlry-plugin-system/Cargo.toml delete mode 100644 crates/owlry-plugin-system/src/lib.rs delete mode 100644 crates/owlry-plugin-systemd/Cargo.toml delete mode 100644 crates/owlry-plugin-systemd/src/lib.rs delete mode 100644 crates/owlry-plugin-weather/Cargo.toml delete mode 100644 crates/owlry-plugin-weather/src/lib.rs delete mode 100644 crates/owlry-plugin-websearch/Cargo.toml delete mode 100644 crates/owlry-plugin-websearch/src/lib.rs delete mode 100644 docs/PLUGINS.md delete mode 100644 docs/PLUGIN_DEVELOPMENT.md diff --git a/Cargo.lock b/Cargo.lock index bff6297..91ebb4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -835,18 +835,6 @@ dependencies = [ "pin-project-lite", ] -[[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" @@ -891,12 +879,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 = "form_urlencoded" version = "1.2.2" @@ -1521,7 +1503,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash 0.1.5", + "foldhash", ] [[package]] @@ -1529,18 +1511,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" @@ -1976,17 +1946,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" @@ -2527,127 +2486,6 @@ dependencies = [ "serde", ] -[[package]] -name = "owlry-plugin-bookmarks" -version = "0.4.10" -dependencies = [ - "abi_stable", - "dirs", - "owlry-plugin-api", - "rusqlite", - "serde", - "serde_json", -] - -[[package]] -name = "owlry-plugin-calculator" -version = "0.4.10" -dependencies = [ - "abi_stable", - "meval", - "owlry-plugin-api", -] - -[[package]] -name = "owlry-plugin-clipboard" -version = "0.4.10" -dependencies = [ - "abi_stable", - "owlry-plugin-api", -] - -[[package]] -name = "owlry-plugin-emoji" -version = "0.4.10" -dependencies = [ - "abi_stable", - "owlry-plugin-api", -] - -[[package]] -name = "owlry-plugin-filesearch" -version = "0.4.10" -dependencies = [ - "abi_stable", - "dirs", - "owlry-plugin-api", -] - -[[package]] -name = "owlry-plugin-media" -version = "0.4.10" -dependencies = [ - "abi_stable", - "owlry-plugin-api", -] - -[[package]] -name = "owlry-plugin-pomodoro" -version = "0.4.10" -dependencies = [ - "abi_stable", - "dirs", - "owlry-plugin-api", - "serde", - "serde_json", - "toml 0.8.23", -] - -[[package]] -name = "owlry-plugin-scripts" -version = "0.4.10" -dependencies = [ - "abi_stable", - "dirs", - "owlry-plugin-api", -] - -[[package]] -name = "owlry-plugin-ssh" -version = "0.4.10" -dependencies = [ - "abi_stable", - "dirs", - "owlry-plugin-api", -] - -[[package]] -name = "owlry-plugin-system" -version = "0.4.10" -dependencies = [ - "abi_stable", - "owlry-plugin-api", -] - -[[package]] -name = "owlry-plugin-systemd" -version = "0.4.10" -dependencies = [ - "abi_stable", - "owlry-plugin-api", -] - -[[package]] -name = "owlry-plugin-weather" -version = "0.4.10" -dependencies = [ - "abi_stable", - "dirs", - "owlry-plugin-api", - "reqwest 0.13.2", - "serde", - "serde_json", - "toml 0.8.23", -] - -[[package]] -name = "owlry-plugin-websearch" -version = "0.4.10" -dependencies = [ - "abi_stable", - "owlry-plugin-api", -] - [[package]] name = "owlry-rune" version = "0.4.10" @@ -3129,16 +2967,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[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 = "rune" version = "0.14.1" @@ -3247,21 +3075,6 @@ dependencies = [ "syn 2.0.117", ] -[[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-hash" version = "2.1.1" @@ -3590,18 +3403,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "sqlite-wasm-rs" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" -dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4218,12 +4019,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version-compare" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 13cb96e..c961699 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,23 +2,10 @@ resolver = "2" members = [ "crates/owlry", + "crates/owlry-core", "crates/owlry-plugin-api", - "crates/owlry-plugin-calculator", - "crates/owlry-plugin-system", - "crates/owlry-plugin-ssh", - "crates/owlry-plugin-clipboard", - "crates/owlry-plugin-emoji", - "crates/owlry-plugin-scripts", - "crates/owlry-plugin-bookmarks", - "crates/owlry-plugin-websearch", - "crates/owlry-plugin-filesearch", - "crates/owlry-plugin-weather", - "crates/owlry-plugin-media", - "crates/owlry-plugin-pomodoro", - "crates/owlry-plugin-systemd", "crates/owlry-lua", "crates/owlry-rune", - "crates/owlry-core", ] # Shared workspace settings diff --git a/crates/owlry-plugin-bookmarks/Cargo.toml b/crates/owlry-plugin-bookmarks/Cargo.toml deleted file mode 100644 index 65f961a..0000000 --- a/crates/owlry-plugin-bookmarks/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "owlry-plugin-bookmarks" -version = "0.4.10" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "Bookmarks plugin for owlry - browser bookmark search" -keywords = ["owlry", "plugin", "bookmarks", "browser"] -categories = ["web-programming"] - -[lib] -crate-type = ["cdylib"] # Compile as dynamic library (.so) - -[dependencies] -# Plugin API for owlry -owlry-plugin-api = { path = "../owlry-plugin-api" } - -# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) -abi_stable = "0.11" - -# For finding browser config directories -dirs = "5.0" - -# For parsing Chrome bookmarks JSON -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -# For reading Firefox bookmarks (places.sqlite) -# Use bundled SQLite to avoid system library version conflicts -rusqlite = { version = "0.39", features = ["bundled"] } diff --git a/crates/owlry-plugin-bookmarks/src/lib.rs b/crates/owlry-plugin-bookmarks/src/lib.rs deleted file mode 100644 index 3eb5f3c..0000000 --- a/crates/owlry-plugin-bookmarks/src/lib.rs +++ /dev/null @@ -1,662 +0,0 @@ -//! Bookmarks Plugin for Owlry -//! -//! A static provider that reads browser bookmarks from various browsers. -//! -//! Supported browsers: -//! - Firefox (via places.sqlite using rusqlite with bundled SQLite) -//! - Chrome -//! - Chromium -//! - Brave -//! - Edge - -use abi_stable::std_types::{ROption, RStr, RString, RVec}; -use owlry_plugin_api::{ - owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, - ProviderPosition, API_VERSION, -}; -use rusqlite::{Connection, OpenFlags}; -use serde::Deserialize; -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::thread; - -// Plugin metadata -const PLUGIN_ID: &str = "bookmarks"; -const PLUGIN_NAME: &str = "Bookmarks"; -const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); -const PLUGIN_DESCRIPTION: &str = "Browser bookmark search"; - -// Provider metadata -const PROVIDER_ID: &str = "bookmarks"; -const PROVIDER_NAME: &str = "Bookmarks"; -const PROVIDER_PREFIX: &str = ":bm"; -const PROVIDER_ICON: &str = "user-bookmarks-symbolic"; -const PROVIDER_TYPE_ID: &str = "bookmarks"; - -/// Bookmarks provider state - holds cached items -struct BookmarksState { - /// Cached bookmark items (returned immediately on refresh) - items: Vec, - /// Flag to prevent concurrent background loads - loading: Arc, -} - -impl BookmarksState { - fn new() -> Self { - Self { - items: Vec::new(), - loading: Arc::new(AtomicBool::new(false)), - } - } - - /// Get or create the favicon cache directory - fn favicon_cache_dir() -> Option { - dirs::cache_dir().map(|d| d.join("owlry/favicons")) - } - - /// Ensure the favicon cache directory exists - fn ensure_favicon_cache_dir() -> Option { - Self::favicon_cache_dir().and_then(|dir| { - fs::create_dir_all(&dir).ok()?; - Some(dir) - }) - } - - /// Hash a URL to create a cache filename - 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()) - } - - /// Get the bookmark cache file path - fn bookmark_cache_file() -> Option { - dirs::cache_dir().map(|d| d.join("owlry/bookmarks.json")) - } - - /// Load cached bookmarks from disk (fast) - fn load_cached_bookmarks() -> Vec { - let cache_file = match Self::bookmark_cache_file() { - Some(f) => f, - None => return Vec::new(), - }; - - if !cache_file.exists() { - return Vec::new(); - } - - let content = match fs::read_to_string(&cache_file) { - Ok(c) => c, - Err(_) => return Vec::new(), - }; - - // Parse cached bookmarks (simple JSON format) - #[derive(serde::Deserialize)] - struct CachedBookmark { - id: String, - name: String, - command: String, - description: Option, - icon: String, - } - - let cached: Vec = match serde_json::from_str(&content) { - Ok(c) => c, - Err(_) => return Vec::new(), - }; - - cached - .into_iter() - .map(|b| { - let mut item = PluginItem::new(b.id, b.name, b.command) - .with_icon(&b.icon) - .with_keywords(vec!["bookmark".to_string()]); - if let Some(desc) = b.description { - item = item.with_description(desc); - } - item - }) - .collect() - } - - /// Save bookmarks to cache file - fn save_cached_bookmarks(items: &[PluginItem]) { - let cache_file = match Self::bookmark_cache_file() { - Some(f) => f, - None => return, - }; - - // Ensure cache directory exists - if let Some(parent) = cache_file.parent() { - let _ = fs::create_dir_all(parent); - } - - #[derive(serde::Serialize)] - struct CachedBookmark { - id: String, - name: String, - command: String, - description: Option, - icon: String, - } - - let cached: Vec = items - .iter() - .map(|item| { - let desc: Option = match &item.description { - abi_stable::std_types::ROption::RSome(s) => Some(s.to_string()), - abi_stable::std_types::ROption::RNone => None, - }; - let icon: String = match &item.icon { - abi_stable::std_types::ROption::RSome(s) => s.to_string(), - abi_stable::std_types::ROption::RNone => PROVIDER_ICON.to_string(), - }; - CachedBookmark { - id: item.id.to_string(), - name: item.name.to_string(), - command: item.command.to_string(), - description: desc, - icon, - } - }) - .collect(); - - if let Ok(json) = serde_json::to_string(&cached) { - let _ = fs::write(&cache_file, json); - } - } - - 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() { - // Find all profile directories - if 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 - } - - /// Find Firefox favicons.sqlite paths (paired with places.sqlite) - fn firefox_favicons_path(places_path: &Path) -> Option { - let favicons = places_path.parent()?.join("favicons.sqlite"); - if favicons.exists() { - Some(favicons) - } else { - None - } - } - - fn load_bookmarks(&mut self) { - // Fast path: load from cache immediately - if self.items.is_empty() { - self.items = Self::load_cached_bookmarks(); - } - - // Don't start another background load if one is already running - if self.loading.swap(true, Ordering::SeqCst) { - return; - } - - // Spawn background thread to refresh bookmarks - let loading = self.loading.clone(); - thread::spawn(move || { - let mut items = Vec::new(); - - // Load Chrome/Chromium bookmarks (fast - just JSON parsing) - for path in Self::chromium_bookmark_paths() { - if path.exists() { - Self::read_chrome_bookmarks_static(&path, &mut items); - } - } - - // Load Firefox bookmarks with favicons (synchronous with rusqlite) - for path in Self::firefox_places_paths() { - Self::read_firefox_bookmarks(&path, &mut items); - } - - // Save to cache for next startup - Self::save_cached_bookmarks(&items); - - loading.store(false, Ordering::SeqCst); - }); - } - - /// Read Chrome bookmarks (static helper for background thread) - fn read_chrome_bookmarks_static(path: &PathBuf, 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_static(&bar, items); - } - if let Some(other) = roots.other { - Self::process_chrome_folder_static(&other, items); - } - if let Some(synced) = roots.synced { - Self::process_chrome_folder_static(&synced, items); - } - } - } - - fn process_chrome_folder_static(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( - PluginItem::new( - format!("bookmark:{}", url), - name, - format!("xdg-open '{}'", url.replace('\'', "'\\''")), - ) - .with_description(url.clone()) - .with_icon(PROVIDER_ICON) - .with_keywords(vec!["bookmark".to_string(), "chrome".to_string()]), - ); - } - } - Some("folder") => { - Self::process_chrome_folder_static(child, items); - } - _ => {} - } - } - } - } - - /// Read Firefox bookmarks using rusqlite (synchronous, bundled SQLite) - fn read_firefox_bookmarks(places_path: &PathBuf, items: &mut Vec) { - let temp_dir = std::env::temp_dir(); - let temp_db = temp_dir.join("owlry_places_temp.sqlite"); - - // Copy database to temp location to avoid locking issues - if fs::copy(places_path, &temp_db).is_err() { - return; - } - - // Also copy WAL file if it exists - let wal_path = places_path.with_extension("sqlite-wal"); - if wal_path.exists() { - let temp_wal = temp_db.with_extension("sqlite-wal"); - let _ = fs::copy(&wal_path, &temp_wal); - } - - // Copy favicons database if available - let favicons_path = Self::firefox_favicons_path(places_path); - let temp_favicons = temp_dir.join("owlry_favicons_temp.sqlite"); - if let Some(ref fp) = favicons_path { - let _ = fs::copy(fp, &temp_favicons); - let fav_wal = fp.with_extension("sqlite-wal"); - if fav_wal.exists() { - let _ = fs::copy(&fav_wal, temp_favicons.with_extension("sqlite-wal")); - } - } - - let cache_dir = Self::ensure_favicon_cache_dir(); - - // Read bookmarks from places.sqlite - let bookmarks = Self::fetch_firefox_bookmarks(&temp_db, &temp_favicons, cache_dir.as_ref()); - - // Clean up temp files - let _ = fs::remove_file(&temp_db); - let _ = fs::remove_file(temp_db.with_extension("sqlite-wal")); - let _ = fs::remove_file(&temp_favicons); - let _ = fs::remove_file(temp_favicons.with_extension("sqlite-wal")); - - for (title, url, favicon_path) in bookmarks { - let icon = favicon_path.unwrap_or_else(|| PROVIDER_ICON.to_string()); - items.push( - PluginItem::new( - format!("bookmark:firefox:{}", url), - title, - format!("xdg-open '{}'", url.replace('\'', "'\\''")), - ) - .with_description(url) - .with_icon(&icon) - .with_keywords(vec!["bookmark".to_string(), "firefox".to_string()]), - ); - } - } - - /// Fetch Firefox bookmarks with optional favicons - fn fetch_firefox_bookmarks( - places_path: &Path, - favicons_path: &Path, - cache_dir: Option<&PathBuf>, - ) -> Vec<(String, String, Option)> { - // Open places.sqlite in read-only mode - 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(), - }; - - // Query bookmarks joining moz_bookmarks with moz_places - // type=1 means URL bookmarks (not folders, separators, etc.) - 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(); - - // If no favicons or cache dir, return without favicons - let cache_dir = match cache_dir { - Some(c) => c, - None => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(), - }; - - // Try to open favicons database - 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(), - }; - - // Fetch favicons for each URL - 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 - } - - /// Get favicon for a URL, caching to file if needed - fn get_favicon_for_url( - conn: &Connection, - page_url: &str, - cache_dir: &Path, - ) -> Option { - // Check if already cached - 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()); - } - - // Query favicon data from database - // Join moz_pages_w_icons -> moz_icons_to_pages -> moz_icons - // Prefer smaller icons (32px) for efficiency - 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; - } - - // Write favicon data to cache file - let mut file = fs::File::create(&cache_path).ok()?; - file.write_all(&data).ok()?; - - Some(cache_path.to_string_lossy().to_string()) - } -} - -// Chrome bookmark JSON structures -#[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>, -} - -// ============================================================================ -// Plugin Interface Implementation -// ============================================================================ - -extern "C" fn plugin_info() -> PluginInfo { - PluginInfo { - id: RString::from(PLUGIN_ID), - name: RString::from(PLUGIN_NAME), - version: RString::from(PLUGIN_VERSION), - description: RString::from(PLUGIN_DESCRIPTION), - api_version: API_VERSION, - } -} - -extern "C" fn plugin_providers() -> RVec { - vec![ProviderInfo { - id: RString::from(PROVIDER_ID), - name: RString::from(PROVIDER_NAME), - prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), - icon: RString::from(PROVIDER_ICON), - provider_type: ProviderKind::Static, - type_id: RString::from(PROVIDER_TYPE_ID), - position: ProviderPosition::Normal, - priority: 0, // Static: use frecency ordering - }] - .into() -} - -extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { - let state = Box::new(BookmarksState::new()); - ProviderHandle::from_box(state) -} - -extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - let state = unsafe { &mut *(handle.ptr as *mut BookmarksState) }; - - // Load bookmarks - state.load_bookmarks(); - - // Return items - state.items.to_vec().into() -} - -extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec { - // Static provider - query is handled by the core using cached items - RVec::new() -} - -extern "C" fn provider_drop(handle: ProviderHandle) { - if !handle.ptr.is_null() { - // SAFETY: We created this handle from Box - unsafe { - handle.drop_as::(); - } - } -} - -// Register the plugin vtable -owlry_plugin! { - info: plugin_info, - providers: plugin_providers, - init: provider_init, - refresh: provider_refresh, - query: provider_query, - drop: provider_drop, -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_bookmarks_state_new() { - let state = BookmarksState::new(); - assert!(state.items.is_empty()); - } - - #[test] - fn test_chromium_paths() { - let paths = BookmarksState::chromium_bookmark_paths(); - // Should have at least some paths configured - assert!(!paths.is_empty()); - } - - #[test] - fn test_firefox_paths() { - // This will find paths if Firefox is installed - let paths = BookmarksState::firefox_places_paths(); - // Path detection should work (may be empty if Firefox not installed) - let _ = paths.len(); // Just ensure it doesn't panic - } - - #[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, - }, - ]), - }; - - BookmarksState::process_chrome_folder_static(&folder, &mut items); - assert_eq!(items.len(), 1); - assert_eq!(items[0].name.as_str(), "Test Bookmark"); - } - - #[test] - fn test_url_escaping() { - let url = "https://example.com/path?query='test'"; - let command = format!("xdg-open '{}'", url.replace('\'', "'\\''")); - assert!(command.contains("'\\''")); - } -} diff --git a/crates/owlry-plugin-calculator/Cargo.toml b/crates/owlry-plugin-calculator/Cargo.toml deleted file mode 100644 index cf2086d..0000000 --- a/crates/owlry-plugin-calculator/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "owlry-plugin-calculator" -version = "0.4.10" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "Calculator plugin for owlry - evaluates mathematical expressions" -keywords = ["owlry", "plugin", "calculator"] -categories = ["mathematics"] - -[lib] -crate-type = ["cdylib"] # Compile as dynamic library (.so) - -[dependencies] -# Plugin API for owlry -owlry-plugin-api = { path = "../owlry-plugin-api" } - -# Math expression evaluation -meval = "0.2" - -# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) -abi_stable = "0.11" diff --git a/crates/owlry-plugin-calculator/src/lib.rs b/crates/owlry-plugin-calculator/src/lib.rs deleted file mode 100644 index dc9ab19..0000000 --- a/crates/owlry-plugin-calculator/src/lib.rs +++ /dev/null @@ -1,231 +0,0 @@ -//! Calculator Plugin for Owlry -//! -//! A dynamic provider that evaluates mathematical expressions. -//! Supports queries prefixed with `=` or `calc `. -//! -//! Examples: -//! - `= 5 + 3` → 8 -//! - `calc sqrt(16)` → 4 -//! - `= pi * 2` → 6.283185... - -use abi_stable::std_types::{ROption, RStr, RString, RVec}; -use owlry_plugin_api::{ - owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, - ProviderPosition, API_VERSION, -}; - -// Plugin metadata -const PLUGIN_ID: &str = "calculator"; -const PLUGIN_NAME: &str = "Calculator"; -const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); -const PLUGIN_DESCRIPTION: &str = "Evaluate mathematical expressions"; - -// Provider metadata -const PROVIDER_ID: &str = "calculator"; -const PROVIDER_NAME: &str = "Calculator"; -const PROVIDER_PREFIX: &str = "="; -const PROVIDER_ICON: &str = "accessories-calculator"; -const PROVIDER_TYPE_ID: &str = "calc"; - -/// Calculator provider state (empty for now, but could cache results) -struct CalculatorState; - -// ============================================================================ -// Plugin Interface Implementation -// ============================================================================ - -extern "C" fn plugin_info() -> PluginInfo { - PluginInfo { - id: RString::from(PLUGIN_ID), - name: RString::from(PLUGIN_NAME), - version: RString::from(PLUGIN_VERSION), - description: RString::from(PLUGIN_DESCRIPTION), - api_version: API_VERSION, - } -} - -extern "C" fn plugin_providers() -> RVec { - vec![ProviderInfo { - id: RString::from(PROVIDER_ID), - name: RString::from(PROVIDER_NAME), - prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), - icon: RString::from(PROVIDER_ICON), - provider_type: ProviderKind::Dynamic, - type_id: RString::from(PROVIDER_TYPE_ID), - position: ProviderPosition::Normal, - priority: 10000, // Dynamic: calculator results first - }] - .into() -} - -extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { - // Create state and return handle - let state = Box::new(CalculatorState); - ProviderHandle::from_box(state) -} - -extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec { - // Dynamic provider - refresh does nothing - RVec::new() -} - -extern "C" fn provider_query(_handle: ProviderHandle, query: RStr<'_>) -> RVec { - let query_str = query.as_str(); - - // Extract expression from query - let expr = match extract_expression(query_str) { - Some(e) if !e.is_empty() => e, - _ => return RVec::new(), - }; - - // Evaluate the expression - match evaluate_expression(expr) { - Some(item) => vec![item].into(), - None => RVec::new(), - } -} - -extern "C" fn provider_drop(handle: ProviderHandle) { - if !handle.ptr.is_null() { - // SAFETY: We created this handle from Box - unsafe { - handle.drop_as::(); - } - } -} - -// Register the plugin vtable -owlry_plugin! { - info: plugin_info, - providers: plugin_providers, - init: provider_init, - refresh: provider_refresh, - query: provider_query, - drop: provider_drop, -} - -// ============================================================================ -// Calculator Logic -// ============================================================================ - -/// Extract expression from query (handles `= expr` and `calc expr` formats) -fn extract_expression(query: &str) -> Option<&str> { - let trimmed = query.trim(); - - // Support both "= expr" and "=expr" (with or without space) - if let Some(expr) = trimmed.strip_prefix("= ") { - Some(expr.trim()) - } else if let Some(expr) = trimmed.strip_prefix('=') { - Some(expr.trim()) - } else if let Some(expr) = trimmed.strip_prefix("calc ") { - Some(expr.trim()) - } else { - // For filter mode - accept raw expressions - Some(trimmed) - } -} - -/// Evaluate a mathematical expression and return a PluginItem -fn evaluate_expression(expr: &str) -> Option { - match meval::eval_str(expr) { - Ok(result) => { - // Format result nicely - let result_str = format_result(result); - - Some( - PluginItem::new( - format!("calc:{}", expr), - result_str.clone(), - format!("sh -c 'echo -n \"{}\" | wl-copy'", result_str), - ) - .with_description(format!("= {}", expr)) - .with_icon(PROVIDER_ICON) - .with_keywords(vec!["math".to_string(), "calculator".to_string()]), - ) - } - Err(_) => None, - } -} - -/// Format a numeric result nicely -fn format_result(result: f64) -> String { - if result.fract() == 0.0 && result.abs() < 1e15 { - // Integer result - format!("{}", result as i64) - } else { - // Float result with reasonable precision, trimming trailing zeros - let formatted = format!("{:.10}", result); - formatted - .trim_end_matches('0') - .trim_end_matches('.') - .to_string() - } -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_expression() { - assert_eq!(extract_expression("= 5+3"), Some("5+3")); - assert_eq!(extract_expression("=5+3"), Some("5+3")); - assert_eq!(extract_expression("calc 5+3"), Some("5+3")); - assert_eq!(extract_expression(" = 5 + 3 "), Some("5 + 3")); - assert_eq!(extract_expression("5+3"), Some("5+3")); // Raw expression - } - - #[test] - fn test_format_result() { - assert_eq!(format_result(8.0), "8"); - assert_eq!(format_result(2.5), "2.5"); - assert_eq!(format_result(3.14159265358979), "3.1415926536"); - } - - #[test] - fn test_evaluate_basic() { - let item = evaluate_expression("5+3").unwrap(); - assert_eq!(item.name.as_str(), "8"); - - let item = evaluate_expression("10 * 2").unwrap(); - assert_eq!(item.name.as_str(), "20"); - - let item = evaluate_expression("15 / 3").unwrap(); - assert_eq!(item.name.as_str(), "5"); - } - - #[test] - fn test_evaluate_float() { - let item = evaluate_expression("5/2").unwrap(); - assert_eq!(item.name.as_str(), "2.5"); - } - - #[test] - fn test_evaluate_functions() { - let item = evaluate_expression("sqrt(16)").unwrap(); - assert_eq!(item.name.as_str(), "4"); - - let item = evaluate_expression("abs(-5)").unwrap(); - assert_eq!(item.name.as_str(), "5"); - } - - #[test] - fn test_evaluate_constants() { - let item = evaluate_expression("pi").unwrap(); - assert!(item.name.as_str().starts_with("3.14159")); - - let item = evaluate_expression("e").unwrap(); - assert!(item.name.as_str().starts_with("2.718")); - } - - #[test] - fn test_evaluate_invalid() { - assert!(evaluate_expression("").is_none()); - assert!(evaluate_expression("invalid").is_none()); - assert!(evaluate_expression("5 +").is_none()); - } -} diff --git a/crates/owlry-plugin-clipboard/Cargo.toml b/crates/owlry-plugin-clipboard/Cargo.toml deleted file mode 100644 index a8e449c..0000000 --- a/crates/owlry-plugin-clipboard/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "owlry-plugin-clipboard" -version = "0.4.10" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "Clipboard plugin for owlry - clipboard history via cliphist" -keywords = ["owlry", "plugin", "clipboard"] -categories = ["os"] - -[lib] -crate-type = ["cdylib"] # Compile as dynamic library (.so) - -[dependencies] -# Plugin API for owlry -owlry-plugin-api = { path = "../owlry-plugin-api" } - -# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) -abi_stable = "0.11" diff --git a/crates/owlry-plugin-clipboard/src/lib.rs b/crates/owlry-plugin-clipboard/src/lib.rs deleted file mode 100644 index 600e67b..0000000 --- a/crates/owlry-plugin-clipboard/src/lib.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! Clipboard Plugin for Owlry -//! -//! A static provider that integrates with cliphist to show clipboard history. -//! Requires cliphist and wl-clipboard to be installed. -//! -//! Dependencies: -//! - cliphist: clipboard history manager -//! - wl-clipboard: Wayland clipboard utilities (wl-copy) - -use abi_stable::std_types::{ROption, RStr, RString, RVec}; -use owlry_plugin_api::{ - owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, - ProviderPosition, API_VERSION, -}; -use std::process::Command; - -// Plugin metadata -const PLUGIN_ID: &str = "clipboard"; -const PLUGIN_NAME: &str = "Clipboard"; -const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); -const PLUGIN_DESCRIPTION: &str = "Clipboard history via cliphist"; - -// Provider metadata -const PROVIDER_ID: &str = "clipboard"; -const PROVIDER_NAME: &str = "Clipboard"; -const PROVIDER_PREFIX: &str = ":clip"; -const PROVIDER_ICON: &str = "edit-paste"; -const PROVIDER_TYPE_ID: &str = "clipboard"; - -// Default max entries to show -const DEFAULT_MAX_ENTRIES: usize = 50; - -/// Clipboard provider state - holds cached items -struct ClipboardState { - items: Vec, - max_entries: usize, -} - -impl ClipboardState { - fn new() -> Self { - Self { - items: Vec::new(), - max_entries: DEFAULT_MAX_ENTRIES, - } - } - - /// Check if cliphist is available - fn has_cliphist() -> bool { - Command::new("which") - .arg("cliphist") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - } - - fn load_clipboard_history(&mut self) { - self.items.clear(); - - if !Self::has_cliphist() { - return; - } - - // Get clipboard history from cliphist - let output = match Command::new("cliphist").arg("list").output() { - Ok(o) => o, - Err(_) => return, - }; - - if !output.status.success() { - return; - } - - let content = String::from_utf8_lossy(&output.stdout); - - for (idx, line) in content.lines().take(self.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 with spaces - 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('\'', "'\\''") - ); - - self.items.push( - PluginItem::new(format!("clipboard:{}", idx), preview_clean, command) - .with_description("Copy to clipboard") - .with_icon(PROVIDER_ICON), - ); - } - } -} - -// ============================================================================ -// Plugin Interface Implementation -// ============================================================================ - -extern "C" fn plugin_info() -> PluginInfo { - PluginInfo { - id: RString::from(PLUGIN_ID), - name: RString::from(PLUGIN_NAME), - version: RString::from(PLUGIN_VERSION), - description: RString::from(PLUGIN_DESCRIPTION), - api_version: API_VERSION, - } -} - -extern "C" fn plugin_providers() -> RVec { - vec![ProviderInfo { - id: RString::from(PROVIDER_ID), - name: RString::from(PROVIDER_NAME), - prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), - icon: RString::from(PROVIDER_ICON), - provider_type: ProviderKind::Static, - type_id: RString::from(PROVIDER_TYPE_ID), - position: ProviderPosition::Normal, - priority: 0, // Static: use frecency ordering - }] - .into() -} - -extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { - let state = Box::new(ClipboardState::new()); - ProviderHandle::from_box(state) -} - -extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - let state = unsafe { &mut *(handle.ptr as *mut ClipboardState) }; - - // Load clipboard history - state.load_clipboard_history(); - - // Return items - state.items.to_vec().into() -} - -extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec { - // Static provider - query is handled by the core using cached items - RVec::new() -} - -extern "C" fn provider_drop(handle: ProviderHandle) { - if !handle.ptr.is_null() { - // SAFETY: We created this handle from Box - unsafe { - handle.drop_as::(); - } - } -} - -// Register the plugin vtable -owlry_plugin! { - info: plugin_info, - providers: plugin_providers, - init: provider_init, - refresh: provider_refresh, - query: provider_query, - drop: provider_drop, -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_clipboard_state_new() { - let state = ClipboardState::new(); - assert!(state.items.is_empty()); - assert_eq!(state.max_entries, DEFAULT_MAX_ENTRIES); - } - - #[test] - fn test_preview_truncation() { - // Test that long strings would be truncated (char-safe) - 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() { - // Test with multi-byte UTF-8 characters (box-drawing chars are 3 bytes each) - let utf8_text = "├── ".repeat(30); // Each "├── " is 7 bytes but 4 chars - 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 _ = ClipboardState::has_cliphist(); - } -} diff --git a/crates/owlry-plugin-emoji/Cargo.toml b/crates/owlry-plugin-emoji/Cargo.toml deleted file mode 100644 index f7b957d..0000000 --- a/crates/owlry-plugin-emoji/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "owlry-plugin-emoji" -version = "0.4.10" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "Emoji plugin for owlry - search and copy emojis" -keywords = ["owlry", "plugin", "emoji"] -categories = ["text-processing"] - -[lib] -crate-type = ["cdylib"] # Compile as dynamic library (.so) - -[dependencies] -# Plugin API for owlry -owlry-plugin-api = { path = "../owlry-plugin-api" } - -# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) -abi_stable = "0.11" diff --git a/crates/owlry-plugin-emoji/src/lib.rs b/crates/owlry-plugin-emoji/src/lib.rs deleted file mode 100644 index 9e08045..0000000 --- a/crates/owlry-plugin-emoji/src/lib.rs +++ /dev/null @@ -1,565 +0,0 @@ -//! Emoji Plugin for Owlry -//! -//! A static provider that provides emoji search and copy functionality. -//! Requires wl-clipboard (wl-copy) for copying to clipboard. -//! -//! Examples: -//! - Search "smile" → 😀 😃 😄 etc. -//! - Search "heart" → ❤️ 💙 💚 etc. - -use abi_stable::std_types::{ROption, RStr, RString, RVec}; -use owlry_plugin_api::{ - owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, - ProviderPosition, API_VERSION, -}; - -// Plugin metadata -const PLUGIN_ID: &str = "emoji"; -const PLUGIN_NAME: &str = "Emoji"; -const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); -const PLUGIN_DESCRIPTION: &str = "Search and copy emojis"; - -// Provider metadata -const PROVIDER_ID: &str = "emoji"; -const PROVIDER_NAME: &str = "Emoji"; -const PROVIDER_PREFIX: &str = ":emoji"; -const PROVIDER_ICON: &str = "face-smile"; -const PROVIDER_TYPE_ID: &str = "emoji"; - -/// Emoji provider state - holds cached items -struct EmojiState { - items: Vec, -} - -impl EmojiState { - fn new() -> Self { - Self { items: Vec::new() } - } - - fn load_emojis(&mut self) { - self.items.clear(); - - // Common emojis with searchable names - // Format: (emoji, name, keywords) - let 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"), - ]; - - for (emoji, name, keywords) in emojis { - self.items.push( - PluginItem::new( - format!("emoji:{}", emoji), - name.to_string(), - format!("printf '%s' '{}' | wl-copy", emoji), - ) - .with_icon(*emoji) // Use emoji character as icon - .with_description(format!("{} {}", emoji, keywords)) - .with_keywords(vec![name.to_string(), keywords.to_string()]), - ); - } - } -} - -// ============================================================================ -// Plugin Interface Implementation -// ============================================================================ - -extern "C" fn plugin_info() -> PluginInfo { - PluginInfo { - id: RString::from(PLUGIN_ID), - name: RString::from(PLUGIN_NAME), - version: RString::from(PLUGIN_VERSION), - description: RString::from(PLUGIN_DESCRIPTION), - api_version: API_VERSION, - } -} - -extern "C" fn plugin_providers() -> RVec { - vec![ProviderInfo { - id: RString::from(PROVIDER_ID), - name: RString::from(PROVIDER_NAME), - prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), - icon: RString::from(PROVIDER_ICON), - provider_type: ProviderKind::Static, - type_id: RString::from(PROVIDER_TYPE_ID), - position: ProviderPosition::Normal, - priority: 0, // Static: use frecency ordering - }] - .into() -} - -extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { - let state = Box::new(EmojiState::new()); - ProviderHandle::from_box(state) -} - -extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - let state = unsafe { &mut *(handle.ptr as *mut EmojiState) }; - - // Load emojis - state.load_emojis(); - - // Return items - state.items.to_vec().into() -} - -extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec { - // Static provider - query is handled by the core using cached items - RVec::new() -} - -extern "C" fn provider_drop(handle: ProviderHandle) { - if !handle.ptr.is_null() { - // SAFETY: We created this handle from Box - unsafe { - handle.drop_as::(); - } - } -} - -// Register the plugin vtable -owlry_plugin! { - info: plugin_info, - providers: plugin_providers, - init: provider_init, - refresh: provider_refresh, - query: provider_query, - drop: provider_drop, -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_emoji_state_new() { - let state = EmojiState::new(); - assert!(state.items.is_empty()); - } - - #[test] - fn test_emoji_count() { - let mut state = EmojiState::new(); - state.load_emojis(); - assert!(state.items.len() > 100, "Should have more than 100 emojis"); - } - - #[test] - fn test_emoji_has_grinning_face() { - let mut state = EmojiState::new(); - state.load_emojis(); - - let grinning = state - .items - .iter() - .find(|i| i.name.as_str() == "grinning face"); - assert!(grinning.is_some()); - - let item = grinning.unwrap(); - assert!(item.description.as_ref().unwrap().as_str().contains("😀")); - } - - #[test] - fn test_emoji_command_format() { - let mut state = EmojiState::new(); - state.load_emojis(); - - let item = &state.items[0]; - assert!(item.command.as_str().contains("wl-copy")); - assert!(item.command.as_str().contains("printf")); - } - - #[test] - fn test_emojis_have_keywords() { - let mut state = EmojiState::new(); - state.load_emojis(); - - // Check that items have keywords for searching - let heart = state - .items - .iter() - .find(|i| i.name.as_str() == "red heart"); - assert!(heart.is_some()); - } -} diff --git a/crates/owlry-plugin-filesearch/Cargo.toml b/crates/owlry-plugin-filesearch/Cargo.toml deleted file mode 100644 index e59c18c..0000000 --- a/crates/owlry-plugin-filesearch/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "owlry-plugin-filesearch" -version = "0.4.10" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "File search plugin for owlry - find files with fd or locate" -keywords = ["owlry", "plugin", "files", "search"] -categories = ["filesystem"] - -[lib] -crate-type = ["cdylib"] # Compile as dynamic library (.so) - -[dependencies] -# Plugin API for owlry -owlry-plugin-api = { path = "../owlry-plugin-api" } - -# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) -abi_stable = "0.11" - -# For finding home directory -dirs = "5.0" diff --git a/crates/owlry-plugin-filesearch/src/lib.rs b/crates/owlry-plugin-filesearch/src/lib.rs deleted file mode 100644 index 9eca26a..0000000 --- a/crates/owlry-plugin-filesearch/src/lib.rs +++ /dev/null @@ -1,322 +0,0 @@ -//! File Search Plugin for Owlry -//! -//! A dynamic provider that searches for files using `fd` or `locate`. -//! -//! Examples: -//! - `/ config.toml` → Search for files matching "config.toml" -//! - `file bashrc` → Search for files matching "bashrc" -//! - `find readme` → Search for files matching "readme" -//! -//! Dependencies: -//! - fd (preferred) or locate - -use abi_stable::std_types::{ROption, RStr, RString, RVec}; -use owlry_plugin_api::{ - owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, - ProviderPosition, API_VERSION, -}; -use std::path::Path; -use std::process::Command; - -// Plugin metadata -const PLUGIN_ID: &str = "filesearch"; -const PLUGIN_NAME: &str = "File Search"; -const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); -const PLUGIN_DESCRIPTION: &str = "Find files with fd or locate"; - -// Provider metadata -const PROVIDER_ID: &str = "filesearch"; -const PROVIDER_NAME: &str = "Files"; -const PROVIDER_PREFIX: &str = "/"; -const PROVIDER_ICON: &str = "folder"; -const PROVIDER_TYPE_ID: &str = "filesearch"; - -// Maximum results to return -const MAX_RESULTS: usize = 20; - -#[derive(Debug, Clone, Copy)] -enum SearchTool { - Fd, - Locate, - None, -} - -/// File search provider state -struct FileSearchState { - search_tool: SearchTool, - home: String, -} - -impl FileSearchState { - fn new() -> Self { - let search_tool = Self::detect_search_tool(); - 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) - } - } - } - - /// Evaluate a query and return file results - 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) - }; - - // Get filename for display - let filename = Path::new(&full_path) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| full_path.clone()); - - // Determine icon based on whether it's a directory - let is_dir = Path::new(&full_path).is_dir(); - let icon = if is_dir { "folder" } else { "text-x-generic" }; - - // Command to open with xdg-open - let command = format!("xdg-open '{}'", full_path.replace('\'', "'\\''")); - - PluginItem::new(format!("file:{}", full_path), filename, command) - .with_description(full_path.clone()) - .with_icon(icon) - .with_keywords(vec!["file".to_string()]) - }) - .collect() - } -} - -// ============================================================================ -// Plugin Interface Implementation -// ============================================================================ - -extern "C" fn plugin_info() -> PluginInfo { - PluginInfo { - id: RString::from(PLUGIN_ID), - name: RString::from(PLUGIN_NAME), - version: RString::from(PLUGIN_VERSION), - description: RString::from(PLUGIN_DESCRIPTION), - api_version: API_VERSION, - } -} - -extern "C" fn plugin_providers() -> RVec { - vec![ProviderInfo { - id: RString::from(PROVIDER_ID), - name: RString::from(PROVIDER_NAME), - prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), - icon: RString::from(PROVIDER_ICON), - provider_type: ProviderKind::Dynamic, - type_id: RString::from(PROVIDER_TYPE_ID), - position: ProviderPosition::Normal, - priority: 8000, // Dynamic: file search - }] - .into() -} - -extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { - let state = Box::new(FileSearchState::new()); - ProviderHandle::from_box(state) -} - -extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec { - // Dynamic provider - refresh does nothing - RVec::new() -} - -extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - let state = unsafe { &*(handle.ptr as *const FileSearchState) }; - - let query_str = query.as_str(); - - state.evaluate(query_str).into() -} - -extern "C" fn provider_drop(handle: ProviderHandle) { - if !handle.ptr.is_null() { - // SAFETY: We created this handle from Box - unsafe { - handle.drop_as::(); - } - } -} - -// Register the plugin vtable -owlry_plugin! { - info: plugin_info, - providers: plugin_providers, - init: provider_init, - refresh: provider_refresh, - query: provider_query, - drop: provider_drop, -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_search_term() { - assert_eq!( - FileSearchState::extract_search_term("/ config.toml"), - Some("config.toml") - ); - assert_eq!( - FileSearchState::extract_search_term("/config"), - Some("config") - ); - assert_eq!( - FileSearchState::extract_search_term("file bashrc"), - Some("bashrc") - ); - assert_eq!( - FileSearchState::extract_search_term("find readme"), - Some("readme") - ); - } - - #[test] - fn test_extract_search_term_empty() { - assert_eq!(FileSearchState::extract_search_term("/"), Some("")); - assert_eq!(FileSearchState::extract_search_term("/ "), Some("")); - } - - #[test] - fn test_command_exists() { - // 'which' should exist on any Unix system - assert!(FileSearchState::command_exists("which")); - // This should not exist - assert!(!FileSearchState::command_exists("nonexistent-command-12345")); - } - - #[test] - fn test_detect_search_tool() { - // Just ensure it doesn't panic - let _ = FileSearchState::detect_search_tool(); - } - - #[test] - fn test_state_new() { - let state = FileSearchState::new(); - assert!(!state.home.is_empty()); - } - - #[test] - fn test_evaluate_empty() { - let state = FileSearchState::new(); - let results = state.evaluate("/"); - assert!(results.is_empty()); - - let results = state.evaluate("/ "); - assert!(results.is_empty()); - } -} diff --git a/crates/owlry-plugin-media/Cargo.toml b/crates/owlry-plugin-media/Cargo.toml deleted file mode 100644 index 9bb3c4b..0000000 --- a/crates/owlry-plugin-media/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "owlry-plugin-media" -version = "0.4.10" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "MPRIS media player widget plugin for owlry - shows and controls currently playing media. Requires playerctl." -keywords = ["owlry", "plugin", "media", "mpris", "widget", "playerctl"] -categories = ["gui"] - -# System dependencies (for packagers): -# - playerctl: for media control commands - -[lib] -crate-type = ["cdylib"] # Compile as dynamic library (.so) - -[dependencies] -# Plugin API for owlry -owlry-plugin-api = { path = "../owlry-plugin-api" } - -# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) -abi_stable = "0.11" diff --git a/crates/owlry-plugin-media/src/lib.rs b/crates/owlry-plugin-media/src/lib.rs deleted file mode 100644 index 0b064c2..0000000 --- a/crates/owlry-plugin-media/src/lib.rs +++ /dev/null @@ -1,468 +0,0 @@ -//! MPRIS Media Player Widget Plugin for Owlry -//! -//! Shows currently playing track as a single row with play/pause action. -//! Uses D-Bus via dbus-send to communicate with MPRIS-compatible players. - -use abi_stable::std_types::{ROption, RStr, RString, RVec}; -use owlry_plugin_api::{ - owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, - ProviderPosition, API_VERSION, -}; -use std::process::Command; - -// Plugin metadata -const PLUGIN_ID: &str = "media"; -const PLUGIN_NAME: &str = "Media Player"; -const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); -const PLUGIN_DESCRIPTION: &str = "MPRIS media player widget - shows and controls currently playing media"; - -// Provider metadata -const PROVIDER_ID: &str = "media"; -const PROVIDER_NAME: &str = "Media"; -const PROVIDER_ICON: &str = "applications-multimedia"; -const PROVIDER_TYPE_ID: &str = "media"; - -#[derive(Debug, Default, Clone)] -struct MediaState { - player_name: String, - title: String, - artist: String, - is_playing: bool, -} - -/// Media provider state -struct MediaProviderState { - items: Vec, - /// Current player name for submenu actions - current_player: Option, - /// Current playback state - is_playing: bool, -} - -impl MediaProviderState { - fn new() -> Self { - // Don't query D-Bus during init - defer to first refresh() call - // This prevents blocking the main thread during startup - Self { - items: Vec::new(), - current_player: None, - is_playing: false, - } - } - - fn refresh(&mut self) { - self.items.clear(); - - let players = Self::find_players(); - if players.is_empty() { - return; - } - - // Find first active player - for player in &players { - if let Some(state) = Self::get_player_state(player) { - self.generate_items(&state); - return; - } - } - } - - /// Find active MPRIS players via dbus-send - fn find_players() -> Vec { - let output = Command::new("dbus-send") - .args([ - "--session", - "--dest=org.freedesktop.DBus", - "--type=method_call", - "--print-reply", - "/org/freedesktop/DBus", - "org.freedesktop.DBus.ListNames", - ]) - .output(); - - match output { - Ok(out) => { - let stdout = String::from_utf8_lossy(&out.stdout); - stdout - .lines() - .filter_map(|line| { - let trimmed = line.trim(); - if trimmed.starts_with("string \"org.mpris.MediaPlayer2.") { - let start = "string \"org.mpris.MediaPlayer2.".len(); - let end = trimmed.len() - 1; - Some(trimmed[start..end].to_string()) - } else { - None - } - }) - .collect() - } - Err(_) => Vec::new(), - } - } - - /// Get metadata from an MPRIS player - fn get_player_state(player: &str) -> Option { - let dest = format!("org.mpris.MediaPlayer2.{}", player); - - // Get playback status - let status_output = Command::new("dbus-send") - .args([ - "--session", - &format!("--dest={}", dest), - "--type=method_call", - "--print-reply", - "/org/mpris/MediaPlayer2", - "org.freedesktop.DBus.Properties.Get", - "string:org.mpris.MediaPlayer2.Player", - "string:PlaybackStatus", - ]) - .output() - .ok()?; - - let status_str = String::from_utf8_lossy(&status_output.stdout); - let is_playing = status_str.contains("\"Playing\""); - let is_paused = status_str.contains("\"Paused\""); - - // Only show if playing or paused (not stopped) - if !is_playing && !is_paused { - return None; - } - - // Get metadata - let metadata_output = Command::new("dbus-send") - .args([ - "--session", - &format!("--dest={}", dest), - "--type=method_call", - "--print-reply", - "/org/mpris/MediaPlayer2", - "org.freedesktop.DBus.Properties.Get", - "string:org.mpris.MediaPlayer2.Player", - "string:Metadata", - ]) - .output() - .ok()?; - - let metadata_str = String::from_utf8_lossy(&metadata_output.stdout); - - let title = Self::extract_string(&metadata_str, "xesam:title") - .unwrap_or_else(|| "Unknown".to_string()); - let artist = Self::extract_array(&metadata_str, "xesam:artist") - .unwrap_or_else(|| "Unknown".to_string()); - - Some(MediaState { - player_name: player.to_string(), - title, - artist, - is_playing, - }) - } - - /// Extract string value from D-Bus output - fn extract_string(output: &str, key: &str) -> Option { - let key_pattern = format!("\"{}\"", key); - let mut found = false; - - for line in output.lines() { - let trimmed = line.trim(); - if trimmed.contains(&key_pattern) { - found = true; - continue; - } - if found { - if let Some(pos) = trimmed.find("string \"") { - let start = pos + "string \"".len(); - if let Some(end) = trimmed[start..].find('"') { - let value = &trimmed[start..start + end]; - if !value.is_empty() { - return Some(value.to_string()); - } - } - } - if !trimmed.starts_with("variant") { - found = false; - } - } - } - None - } - - /// Extract array value from D-Bus output - fn extract_array(output: &str, key: &str) -> Option { - let key_pattern = format!("\"{}\"", key); - let mut found = false; - let mut in_array = false; - let mut values = Vec::new(); - - for line in output.lines() { - let trimmed = line.trim(); - if trimmed.contains(&key_pattern) { - found = true; - continue; - } - if found && trimmed.contains("array [") { - in_array = true; - continue; - } - if in_array { - if let Some(pos) = trimmed.find("string \"") { - let start = pos + "string \"".len(); - if let Some(end) = trimmed[start..].find('"') { - values.push(trimmed[start..start + end].to_string()); - } - } - if trimmed.contains(']') { - break; - } - } - } - - if values.is_empty() { - None - } else { - Some(values.join(", ")) - } - } - - /// Generate single LaunchItem for media state (opens submenu) - fn generate_items(&mut self, state: &MediaState) { - self.items.clear(); - - // Store state for submenu - self.current_player = Some(state.player_name.clone()); - self.is_playing = state.is_playing; - - // Single row: "Title — Artist" - let name = format!("{} — {}", state.title, state.artist); - - // Extract player display name (e.g., "firefox.instance_1_94" -> "Firefox") - let player_display = Self::format_player_name(&state.player_name); - - // Opens submenu with media controls - self.items.push( - PluginItem::new("media-now-playing", name, "SUBMENU:media:controls") - .with_description(format!("{} · Select for controls", player_display)) - .with_icon("/org/owlry/launcher/icons/media/music-note.svg") - .with_keywords(vec!["media".to_string(), "widget".to_string()]), - ); - } - - /// Format player name for display - fn format_player_name(player_name: &str) -> String { - let player_display = player_name.split('.').next().unwrap_or(player_name); - if player_display.is_empty() { - "Player".to_string() - } else { - let mut chars = player_display.chars(); - match chars.next() { - None => "Player".to_string(), - Some(first) => first.to_uppercase().chain(chars).collect(), - } - } - } - - /// Generate submenu items for media controls - fn generate_submenu_items(&self) -> Vec { - let player = match &self.current_player { - Some(p) => p, - None => return Vec::new(), - }; - - let mut items = Vec::new(); - - // Use playerctl for simpler, more reliable media control - // playerctl -p - - // Play/Pause - if self.is_playing { - items.push( - PluginItem::new( - "media-pause", - "Pause", - format!("playerctl -p {} pause", player), - ) - .with_description("Pause playback") - .with_icon("media-playback-pause"), - ); - } else { - items.push( - PluginItem::new( - "media-play", - "Play", - format!("playerctl -p {} play", player), - ) - .with_description("Resume playback") - .with_icon("media-playback-start"), - ); - } - - // Next track - items.push( - PluginItem::new( - "media-next", - "Next", - format!("playerctl -p {} next", player), - ) - .with_description("Skip to next track") - .with_icon("media-skip-forward"), - ); - - // Previous track - items.push( - PluginItem::new( - "media-previous", - "Previous", - format!("playerctl -p {} previous", player), - ) - .with_description("Go to previous track") - .with_icon("media-skip-backward"), - ); - - // Stop - items.push( - PluginItem::new( - "media-stop", - "Stop", - format!("playerctl -p {} stop", player), - ) - .with_description("Stop playback") - .with_icon("media-playback-stop"), - ); - - items - } -} - -// ============================================================================ -// Plugin Interface Implementation -// ============================================================================ - -extern "C" fn plugin_info() -> PluginInfo { - PluginInfo { - id: RString::from(PLUGIN_ID), - name: RString::from(PLUGIN_NAME), - version: RString::from(PLUGIN_VERSION), - description: RString::from(PLUGIN_DESCRIPTION), - api_version: API_VERSION, - } -} - -extern "C" fn plugin_providers() -> RVec { - vec![ProviderInfo { - id: RString::from(PROVIDER_ID), - name: RString::from(PROVIDER_NAME), - prefix: ROption::RNone, - icon: RString::from(PROVIDER_ICON), - provider_type: ProviderKind::Static, - type_id: RString::from(PROVIDER_TYPE_ID), - position: ProviderPosition::Widget, - priority: 11000, // Widget: media player - }] - .into() -} - -extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { - let state = Box::new(MediaProviderState::new()); - ProviderHandle::from_box(state) -} - -extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - let state = unsafe { &mut *(handle.ptr as *mut MediaProviderState) }; - - state.refresh(); - state.items.clone().into() -} - -extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - let query_str = query.as_str(); - let state = unsafe { &*(handle.ptr as *const MediaProviderState) }; - - // Handle submenu request - if query_str == "?SUBMENU:controls" { - return state.generate_submenu_items().into(); - } - - RVec::new() -} - -extern "C" fn provider_drop(handle: ProviderHandle) { - if !handle.ptr.is_null() { - // SAFETY: We created this handle from Box - unsafe { - handle.drop_as::(); - } - } -} - -// Register the plugin vtable -owlry_plugin! { - info: plugin_info, - providers: plugin_providers, - init: provider_init, - refresh: provider_refresh, - query: provider_query, - drop: provider_drop, -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_string() { - let output = r#" - string "xesam:title" - variant string "My Song Title" - "#; - assert_eq!( - MediaProviderState::extract_string(output, "xesam:title"), - Some("My Song Title".to_string()) - ); - } - - #[test] - fn test_extract_array() { - let output = r#" - string "xesam:artist" - variant array [ - string "Artist One" - string "Artist Two" - ] - "#; - assert_eq!( - MediaProviderState::extract_array(output, "xesam:artist"), - Some("Artist One, Artist Two".to_string()) - ); - } - - #[test] - fn test_extract_string_not_found() { - let output = "some other output"; - assert_eq!( - MediaProviderState::extract_string(output, "xesam:title"), - None - ); - } - - #[test] - fn test_find_players_empty() { - // This will return empty on systems without D-Bus - let players = MediaProviderState::find_players(); - // Just verify it doesn't panic - let _ = players; - } -} diff --git a/crates/owlry-plugin-pomodoro/Cargo.toml b/crates/owlry-plugin-pomodoro/Cargo.toml deleted file mode 100644 index ef24bf5..0000000 --- a/crates/owlry-plugin-pomodoro/Cargo.toml +++ /dev/null @@ -1,30 +0,0 @@ -[package] -name = "owlry-plugin-pomodoro" -version = "0.4.10" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "Pomodoro timer widget plugin for owlry - work/break cycles with persistent state" -keywords = ["owlry", "plugin", "pomodoro", "timer", "widget"] -categories = ["gui"] - -[lib] -crate-type = ["cdylib"] # Compile as dynamic library (.so) - -[dependencies] -# Plugin API for owlry -owlry-plugin-api = { path = "../owlry-plugin-api" } - -# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) -abi_stable = "0.11" - -# JSON serialization for persistent state -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -# TOML config parsing -toml = "0.8" - -# For finding data directory -dirs = "5.0" diff --git a/crates/owlry-plugin-pomodoro/src/lib.rs b/crates/owlry-plugin-pomodoro/src/lib.rs deleted file mode 100644 index 85f4af7..0000000 --- a/crates/owlry-plugin-pomodoro/src/lib.rs +++ /dev/null @@ -1,478 +0,0 @@ -//! Pomodoro Timer Widget Plugin for Owlry -//! -//! Shows timer with work/break cycles. Select to open controls submenu. -//! State persists across sessions via JSON file. -//! -//! ## Configuration -//! -//! Configure via `~/.config/owlry/config.toml`: -//! -//! ```toml -//! [plugins.pomodoro] -//! work_mins = 25 # Work session duration (default: 25) -//! break_mins = 5 # Break duration (default: 5) -//! ``` - -use abi_stable::std_types::{ROption, RStr, RString, RVec}; -use owlry_plugin_api::{ - notify_with_urgency, owlry_plugin, NotifyUrgency, PluginInfo, PluginItem, ProviderHandle, - ProviderInfo, ProviderKind, ProviderPosition, API_VERSION, -}; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; - -// Plugin metadata -const PLUGIN_ID: &str = "pomodoro"; -const PLUGIN_NAME: &str = "Pomodoro Timer"; -const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); -const PLUGIN_DESCRIPTION: &str = "Pomodoro timer widget with work/break cycles"; - -// Provider metadata -const PROVIDER_ID: &str = "pomodoro"; -const PROVIDER_NAME: &str = "Pomodoro"; -const PROVIDER_ICON: &str = "alarm"; -const PROVIDER_TYPE_ID: &str = "pomodoro"; - -// Default timing (in minutes) -const DEFAULT_WORK_MINS: u32 = 25; -const DEFAULT_BREAK_MINS: u32 = 5; - -/// Pomodoro configuration -#[derive(Debug, Clone)] -struct PomodoroConfig { - work_mins: u32, - break_mins: u32, -} - -impl PomodoroConfig { - /// Load config from ~/.config/owlry/config.toml - /// - /// Reads from [plugins.pomodoro] section, with fallback to [providers] for compatibility. - fn load() -> Self { - let config_path = dirs::config_dir() - .map(|d| d.join("owlry").join("config.toml")); - - let config_content = config_path - .and_then(|p| fs::read_to_string(p).ok()); - - if let Some(content) = config_content - && let Ok(toml) = content.parse::() - { - // Try [plugins.pomodoro] first (new format) - if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table()) - && let Some(pomodoro) = plugins.get("pomodoro").and_then(|v| v.as_table()) - { - return Self::from_toml_table(pomodoro); - } - - // Fallback to [providers] section (old format) - if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) { - let work_mins = providers - .get("pomodoro_work_mins") - .and_then(|v| v.as_integer()) - .map(|v| v as u32) - .unwrap_or(DEFAULT_WORK_MINS); - - let break_mins = providers - .get("pomodoro_break_mins") - .and_then(|v| v.as_integer()) - .map(|v| v as u32) - .unwrap_or(DEFAULT_BREAK_MINS); - - return Self { work_mins, break_mins }; - } - } - - // Default config - Self { - work_mins: DEFAULT_WORK_MINS, - break_mins: DEFAULT_BREAK_MINS, - } - } - - /// Parse config from a TOML table - fn from_toml_table(table: &toml::Table) -> Self { - let work_mins = table - .get("work_mins") - .and_then(|v| v.as_integer()) - .map(|v| v as u32) - .unwrap_or(DEFAULT_WORK_MINS); - - let break_mins = table - .get("break_mins") - .and_then(|v| v.as_integer()) - .map(|v| v as u32) - .unwrap_or(DEFAULT_BREAK_MINS); - - Self { work_mins, break_mins } - } -} - -/// Timer phase -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)] -enum PomodoroPhase { - #[default] - Idle, - Working, - WorkPaused, - Break, - BreakPaused, -} - -/// Persistent state (saved to disk) -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -struct PomodoroState { - phase: PomodoroPhase, - remaining_secs: u32, - sessions: u32, - last_update: u64, -} - -/// Pomodoro provider state -struct PomodoroProviderState { - items: Vec, - state: PomodoroState, - work_mins: u32, - break_mins: u32, -} - -impl PomodoroProviderState { - fn new() -> Self { - let config = PomodoroConfig::load(); - - let state = Self::load_state().unwrap_or_else(|| PomodoroState { - phase: PomodoroPhase::Idle, - remaining_secs: config.work_mins * 60, - sessions: 0, - last_update: Self::now_secs(), - }); - - let mut provider = Self { - items: Vec::new(), - state, - work_mins: config.work_mins, - break_mins: config.break_mins, - }; - - provider.update_elapsed_time(); - provider.generate_items(); - provider - } - - fn now_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - } - - fn data_dir() -> Option { - dirs::data_dir().map(|d| d.join("owlry")) - } - - fn load_state() -> Option { - let path = Self::data_dir()?.join("pomodoro.json"); - let content = fs::read_to_string(&path).ok()?; - serde_json::from_str(&content).ok() - } - - fn save_state(&self) { - if let Some(data_dir) = Self::data_dir() { - let path = data_dir.join("pomodoro.json"); - if fs::create_dir_all(&data_dir).is_err() { - return; - } - let mut state = self.state.clone(); - state.last_update = Self::now_secs(); - if let Ok(json) = serde_json::to_string_pretty(&state) { - let _ = fs::write(&path, json); - } - } - } - - fn update_elapsed_time(&mut self) { - let now = Self::now_secs(); - let elapsed = now.saturating_sub(self.state.last_update); - - match self.state.phase { - PomodoroPhase::Working | PomodoroPhase::Break => { - if elapsed >= self.state.remaining_secs as u64 { - self.complete_phase(); - } else { - self.state.remaining_secs -= elapsed as u32; - } - } - _ => {} - } - self.state.last_update = now; - } - - fn complete_phase(&mut self) { - match self.state.phase { - PomodoroPhase::Working => { - self.state.sessions += 1; - self.state.phase = PomodoroPhase::Break; - self.state.remaining_secs = self.break_mins * 60; - notify_with_urgency( - "Pomodoro Complete!", - &format!( - "Great work! Session {} complete. Time for a {}-minute break.", - self.state.sessions, self.break_mins - ), - "alarm", - NotifyUrgency::Normal, - ); - } - PomodoroPhase::Break => { - self.state.phase = PomodoroPhase::Idle; - self.state.remaining_secs = self.work_mins * 60; - notify_with_urgency( - "Break Complete", - "Break time's over! Ready for another work session?", - "alarm", - NotifyUrgency::Normal, - ); - } - _ => {} - } - self.save_state(); - } - - fn refresh(&mut self) { - self.update_elapsed_time(); - self.generate_items(); - } - - fn handle_action(&mut self, action: &str) { - match action { - "start" => { - self.state.phase = PomodoroPhase::Working; - self.state.remaining_secs = self.work_mins * 60; - self.state.last_update = Self::now_secs(); - } - "pause" => match self.state.phase { - PomodoroPhase::Working => self.state.phase = PomodoroPhase::WorkPaused, - PomodoroPhase::Break => self.state.phase = PomodoroPhase::BreakPaused, - _ => {} - }, - "resume" => { - self.state.last_update = Self::now_secs(); - match self.state.phase { - PomodoroPhase::WorkPaused => self.state.phase = PomodoroPhase::Working, - PomodoroPhase::BreakPaused => self.state.phase = PomodoroPhase::Break, - _ => {} - } - } - "skip" => self.complete_phase(), - "reset" => { - self.state.phase = PomodoroPhase::Idle; - self.state.remaining_secs = self.work_mins * 60; - self.state.sessions = 0; - } - _ => {} - } - self.save_state(); - self.generate_items(); - } - - fn format_time(secs: u32) -> String { - let mins = secs / 60; - let secs = secs % 60; - format!("{:02}:{:02}", mins, secs) - } - - /// Generate single main item with submenu for controls - fn generate_items(&mut self) { - self.items.clear(); - - let (phase_name, _is_running) = match self.state.phase { - PomodoroPhase::Idle => ("Ready", false), - PomodoroPhase::Working => ("Work", true), - PomodoroPhase::WorkPaused => ("Paused", false), - PomodoroPhase::Break => ("Break", true), - PomodoroPhase::BreakPaused => ("Paused", false), - }; - - let time_str = Self::format_time(self.state.remaining_secs); - let name = format!("{}: {}", phase_name, time_str); - - let description = if self.state.sessions > 0 { - format!( - "Sessions: {} | {}min work / {}min break", - self.state.sessions, self.work_mins, self.break_mins - ) - } else { - format!("{}min work / {}min break", self.work_mins, self.break_mins) - }; - - // Single item that opens submenu with controls - self.items.push( - PluginItem::new("pomo-timer", name, "SUBMENU:pomodoro:controls") - .with_description(description) - .with_icon("/org/owlry/launcher/icons/pomodoro/tomato.svg") - .with_keywords(vec![ - "pomodoro".to_string(), - "widget".to_string(), - "timer".to_string(), - ]), - ); - } - - /// Generate submenu items for controls - fn generate_submenu_items(&self) -> Vec { - let mut items = Vec::new(); - let is_running = matches!( - self.state.phase, - PomodoroPhase::Working | PomodoroPhase::Break - ); - - // Primary control: Start/Pause/Resume - if is_running { - items.push( - PluginItem::new("pomo-pause", "Pause", "POMODORO:pause") - .with_description("Pause the timer") - .with_icon("media-playback-pause"), - ); - } else { - match self.state.phase { - PomodoroPhase::Idle => { - items.push( - PluginItem::new("pomo-start", "Start Work", "POMODORO:start") - .with_description("Start a new work session") - .with_icon("media-playback-start"), - ); - } - _ => { - items.push( - PluginItem::new("pomo-resume", "Resume", "POMODORO:resume") - .with_description("Resume the timer") - .with_icon("media-playback-start"), - ); - } - } - } - - // Skip (only when not idle) - if self.state.phase != PomodoroPhase::Idle { - items.push( - PluginItem::new("pomo-skip", "Skip", "POMODORO:skip") - .with_description("Skip to next phase") - .with_icon("media-skip-forward"), - ); - } - - // Reset - items.push( - PluginItem::new("pomo-reset", "Reset", "POMODORO:reset") - .with_description("Reset timer and sessions") - .with_icon("view-refresh"), - ); - - items - } -} - -// ============================================================================ -// Plugin Interface Implementation -// ============================================================================ - -extern "C" fn plugin_info() -> PluginInfo { - PluginInfo { - id: RString::from(PLUGIN_ID), - name: RString::from(PLUGIN_NAME), - version: RString::from(PLUGIN_VERSION), - description: RString::from(PLUGIN_DESCRIPTION), - api_version: API_VERSION, - } -} - -extern "C" fn plugin_providers() -> RVec { - vec![ProviderInfo { - id: RString::from(PROVIDER_ID), - name: RString::from(PROVIDER_NAME), - prefix: ROption::RNone, - icon: RString::from(PROVIDER_ICON), - provider_type: ProviderKind::Static, - type_id: RString::from(PROVIDER_TYPE_ID), - position: ProviderPosition::Widget, - priority: 11500, // Widget: pomodoro timer - }] - .into() -} - -extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { - let state = Box::new(PomodoroProviderState::new()); - ProviderHandle::from_box(state) -} - -extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - let state = unsafe { &mut *(handle.ptr as *mut PomodoroProviderState) }; - state.refresh(); - state.items.clone().into() -} - -extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - let query_str = query.as_str(); - let state = unsafe { &mut *(handle.ptr as *mut PomodoroProviderState) }; - - // Handle submenu request - if query_str == "?SUBMENU:controls" { - return state.generate_submenu_items().into(); - } - - // Handle action commands - if let Some(action) = query_str.strip_prefix("!POMODORO:") { - state.handle_action(action); - } - - RVec::new() -} - -extern "C" fn provider_drop(handle: ProviderHandle) { - if !handle.ptr.is_null() { - let state = unsafe { &*(handle.ptr as *const PomodoroProviderState) }; - state.save_state(); - unsafe { - handle.drop_as::(); - } - } -} - -owlry_plugin! { - info: plugin_info, - providers: plugin_providers, - init: provider_init, - refresh: provider_refresh, - query: provider_query, - drop: provider_drop, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_format_time() { - assert_eq!(PomodoroProviderState::format_time(0), "00:00"); - assert_eq!(PomodoroProviderState::format_time(60), "01:00"); - assert_eq!(PomodoroProviderState::format_time(90), "01:30"); - assert_eq!(PomodoroProviderState::format_time(1500), "25:00"); - assert_eq!(PomodoroProviderState::format_time(3599), "59:59"); - } - - #[test] - fn test_default_phase() { - let phase: PomodoroPhase = Default::default(); - assert_eq!(phase, PomodoroPhase::Idle); - } -} diff --git a/crates/owlry-plugin-scripts/Cargo.toml b/crates/owlry-plugin-scripts/Cargo.toml deleted file mode 100644 index 824e206..0000000 --- a/crates/owlry-plugin-scripts/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "owlry-plugin-scripts" -version = "0.4.10" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "Scripts plugin for owlry - run user scripts from ~/.local/share/owlry/scripts/" -keywords = ["owlry", "plugin", "scripts"] -categories = ["os"] - -[lib] -crate-type = ["cdylib"] # Compile as dynamic library (.so) - -[dependencies] -# Plugin API for owlry -owlry-plugin-api = { path = "../owlry-plugin-api" } - -# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) -abi_stable = "0.11" - -# For finding ~/.local/share/owlry/scripts -dirs = "5.0" diff --git a/crates/owlry-plugin-scripts/src/lib.rs b/crates/owlry-plugin-scripts/src/lib.rs deleted file mode 100644 index c49efa4..0000000 --- a/crates/owlry-plugin-scripts/src/lib.rs +++ /dev/null @@ -1,290 +0,0 @@ -//! Scripts Plugin for Owlry -//! -//! A static provider that scans `~/.local/share/owlry/scripts/` for executable -//! scripts and provides them as launch items. -//! -//! Scripts can include a description by adding a comment after the shebang: -//! ```bash -//! #!/bin/bash -//! # This is my script description -//! echo "Hello" -//! ``` - -use abi_stable::std_types::{ROption, RStr, RString, RVec}; -use owlry_plugin_api::{ - owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, - ProviderPosition, API_VERSION, -}; -use std::fs; -use std::os::unix::fs::PermissionsExt; -use std::path::PathBuf; - -// Plugin metadata -const PLUGIN_ID: &str = "scripts"; -const PLUGIN_NAME: &str = "Scripts"; -const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); -const PLUGIN_DESCRIPTION: &str = "Run user scripts from ~/.local/share/owlry/scripts/"; - -// Provider metadata -const PROVIDER_ID: &str = "scripts"; -const PROVIDER_NAME: &str = "Scripts"; -const PROVIDER_PREFIX: &str = ":script"; -const PROVIDER_ICON: &str = "utilities-terminal"; -const PROVIDER_TYPE_ID: &str = "scripts"; - -/// Scripts provider state - holds cached items -struct ScriptsState { - items: Vec, -} - -impl ScriptsState { - fn new() -> Self { - Self { items: Vec::new() } - } - - fn scripts_dir() -> Option { - dirs::data_dir().map(|d| d.join("owlry").join("scripts")) - } - - fn load_scripts(&mut self) { - self.items.clear(); - - let scripts_dir = match Self::scripts_dir() { - Some(p) => p, - None => return, - }; - - if !scripts_dir.exists() { - // Create the directory for the user - let _ = fs::create_dir_all(&scripts_dir); - return; - } - - let entries = match fs::read_dir(&scripts_dir) { - Ok(e) => e, - Err(_) => return, - }; - - for entry in entries.flatten() { - let path = entry.path(); - - // Skip directories - if path.is_dir() { - continue; - } - - // Check if executable - let metadata = match path.metadata() { - Ok(m) => m, - Err(_) => continue, - }; - - let is_executable = metadata.permissions().mode() & 0o111 != 0; - if !is_executable { - continue; - } - - // Get script name without extension - let filename = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - - let name = path - .file_stem() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or(filename.clone()); - - // Try to read description from first line comment - let description = Self::read_script_description(&path); - - // Determine icon based on extension or shebang - let icon = Self::determine_icon(&path); - - let mut item = PluginItem::new( - format!("script:{}", filename), - format!("Script: {}", name), - path.to_string_lossy().to_string(), - ) - .with_icon(icon) - .with_keywords(vec!["script".to_string()]); - - if let Some(desc) = description { - item = item.with_description(desc); - } - - self.items.push(item); - } - } - - fn read_script_description(path: &PathBuf) -> Option { - let content = fs::read_to_string(path).ok()?; - let mut lines = content.lines(); - - // Skip shebang if present - let first_line = lines.next()?; - let check_line = if first_line.starts_with("#!") { - lines.next()? - } else { - first_line - }; - - // Look for a comment description - if let Some(desc) = check_line.strip_prefix("# ") { - Some(desc.trim().to_string()) - } else { check_line.strip_prefix("// ").map(|desc| desc.trim().to_string()) } - } - - fn determine_icon(path: &PathBuf) -> String { - // Check extension first - if let Some(ext) = path.extension() { - match ext.to_string_lossy().as_ref() { - "sh" | "bash" | "zsh" => return "utilities-terminal".to_string(), - "py" | "python" => return "text-x-python".to_string(), - "js" | "ts" => return "text-x-javascript".to_string(), - "rb" => return "text-x-ruby".to_string(), - "pl" => return "text-x-perl".to_string(), - _ => {} - } - } - - // Check shebang - if let Ok(content) = fs::read_to_string(path) - && let Some(first_line) = content.lines().next() { - if first_line.contains("bash") || first_line.contains("sh") { - return "utilities-terminal".to_string(); - } else if first_line.contains("python") { - return "text-x-python".to_string(); - } else if first_line.contains("node") { - return "text-x-javascript".to_string(); - } else if first_line.contains("ruby") { - return "text-x-ruby".to_string(); - } - } - - "application-x-executable".to_string() - } -} - -// ============================================================================ -// Plugin Interface Implementation -// ============================================================================ - -extern "C" fn plugin_info() -> PluginInfo { - PluginInfo { - id: RString::from(PLUGIN_ID), - name: RString::from(PLUGIN_NAME), - version: RString::from(PLUGIN_VERSION), - description: RString::from(PLUGIN_DESCRIPTION), - api_version: API_VERSION, - } -} - -extern "C" fn plugin_providers() -> RVec { - vec![ProviderInfo { - id: RString::from(PROVIDER_ID), - name: RString::from(PROVIDER_NAME), - prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), - icon: RString::from(PROVIDER_ICON), - provider_type: ProviderKind::Static, - type_id: RString::from(PROVIDER_TYPE_ID), - position: ProviderPosition::Normal, - priority: 0, // Static: use frecency ordering - }] - .into() -} - -extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { - let state = Box::new(ScriptsState::new()); - ProviderHandle::from_box(state) -} - -extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - let state = unsafe { &mut *(handle.ptr as *mut ScriptsState) }; - - // Load scripts - state.load_scripts(); - - // Return items - state.items.to_vec().into() -} - -extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec { - // Static provider - query is handled by the core using cached items - RVec::new() -} - -extern "C" fn provider_drop(handle: ProviderHandle) { - if !handle.ptr.is_null() { - // SAFETY: We created this handle from Box - unsafe { - handle.drop_as::(); - } - } -} - -// Register the plugin vtable -owlry_plugin! { - info: plugin_info, - providers: plugin_providers, - init: provider_init, - refresh: provider_refresh, - query: provider_query, - drop: provider_drop, -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_scripts_state_new() { - let state = ScriptsState::new(); - assert!(state.items.is_empty()); - } - - #[test] - fn test_determine_icon_sh() { - let path = PathBuf::from("/test/script.sh"); - let icon = ScriptsState::determine_icon(&path); - assert_eq!(icon, "utilities-terminal"); - } - - #[test] - fn test_determine_icon_python() { - let path = PathBuf::from("/test/script.py"); - let icon = ScriptsState::determine_icon(&path); - assert_eq!(icon, "text-x-python"); - } - - #[test] - fn test_determine_icon_js() { - let path = PathBuf::from("/test/script.js"); - let icon = ScriptsState::determine_icon(&path); - assert_eq!(icon, "text-x-javascript"); - } - - #[test] - fn test_determine_icon_unknown() { - let path = PathBuf::from("/test/script.xyz"); - let icon = ScriptsState::determine_icon(&path); - assert_eq!(icon, "application-x-executable"); - } - - #[test] - fn test_scripts_dir() { - // Should return Some path - let dir = ScriptsState::scripts_dir(); - assert!(dir.is_some()); - assert!(dir.unwrap().ends_with("owlry/scripts")); - } -} diff --git a/crates/owlry-plugin-ssh/Cargo.toml b/crates/owlry-plugin-ssh/Cargo.toml deleted file mode 100644 index 0e3c456..0000000 --- a/crates/owlry-plugin-ssh/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "owlry-plugin-ssh" -version = "0.4.10" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "SSH plugin for owlry - quick connect to SSH hosts from ~/.ssh/config" -keywords = ["owlry", "plugin", "ssh"] -categories = ["network-programming"] - -[lib] -crate-type = ["cdylib"] # Compile as dynamic library (.so) - -[dependencies] -# Plugin API for owlry -owlry-plugin-api = { path = "../owlry-plugin-api" } - -# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) -abi_stable = "0.11" - -# For finding ~/.ssh/config -dirs = "5.0" diff --git a/crates/owlry-plugin-ssh/src/lib.rs b/crates/owlry-plugin-ssh/src/lib.rs deleted file mode 100644 index 01e889b..0000000 --- a/crates/owlry-plugin-ssh/src/lib.rs +++ /dev/null @@ -1,328 +0,0 @@ -//! SSH Plugin for Owlry -//! -//! A static provider that parses ~/.ssh/config and provides quick-connect -//! entries for SSH hosts. -//! -//! Examples: -//! - `SSH: myserver` → Connect to myserver -//! - `SSH: work-box` → Connect to work-box with configured user/port - -use abi_stable::std_types::{ROption, RStr, RString, RVec}; -use owlry_plugin_api::{ - owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, - ProviderPosition, API_VERSION, -}; -use std::fs; -use std::path::PathBuf; - -// Plugin metadata -const PLUGIN_ID: &str = "ssh"; -const PLUGIN_NAME: &str = "SSH"; -const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); -const PLUGIN_DESCRIPTION: &str = "Quick connect to SSH hosts from ~/.ssh/config"; - -// Provider metadata -const PROVIDER_ID: &str = "ssh"; -const PROVIDER_NAME: &str = "SSH"; -const PROVIDER_PREFIX: &str = ":ssh"; -const PROVIDER_ICON: &str = "utilities-terminal"; -const PROVIDER_TYPE_ID: &str = "ssh"; - -// Default terminal command (TODO: make configurable via plugin config) -const DEFAULT_TERMINAL: &str = "kitty"; - -/// SSH provider state - holds cached items -struct SshState { - items: Vec, - terminal_command: String, -} - -impl SshState { - fn new() -> Self { - // Try to detect terminal from environment, fall back to default - let terminal = std::env::var("TERMINAL") - .unwrap_or_else(|_| DEFAULT_TERMINAL.to_string()); - - Self { - items: Vec::new(), - terminal_command: terminal, - } - } - - 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); - - let mut item = PluginItem::new( - format!("ssh:{}", host), - format!("SSH: {}", host), - command, - ) - .with_icon(PROVIDER_ICON) - .with_keywords(vec!["ssh".to_string(), "remote".to_string()]); - - if let Some(desc) = description { - item = item.with_description(desc); - } - - self.items.push(item); - } -} - -// ============================================================================ -// Plugin Interface Implementation -// ============================================================================ - -extern "C" fn plugin_info() -> PluginInfo { - PluginInfo { - id: RString::from(PLUGIN_ID), - name: RString::from(PLUGIN_NAME), - version: RString::from(PLUGIN_VERSION), - description: RString::from(PLUGIN_DESCRIPTION), - api_version: API_VERSION, - } -} - -extern "C" fn plugin_providers() -> RVec { - vec![ProviderInfo { - id: RString::from(PROVIDER_ID), - name: RString::from(PROVIDER_NAME), - prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), - icon: RString::from(PROVIDER_ICON), - provider_type: ProviderKind::Static, - type_id: RString::from(PROVIDER_TYPE_ID), - position: ProviderPosition::Normal, - priority: 0, // Static: use frecency ordering - }] - .into() -} - -extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { - let state = Box::new(SshState::new()); - ProviderHandle::from_box(state) -} - -extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - let state = unsafe { &mut *(handle.ptr as *mut SshState) }; - - // Parse SSH config - state.parse_ssh_config(); - - // Return items - state.items.to_vec().into() -} - -extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec { - // Static provider - query is handled by the core using cached items - RVec::new() -} - -extern "C" fn provider_drop(handle: ProviderHandle) { - if !handle.ptr.is_null() { - // SAFETY: We created this handle from Box - unsafe { - handle.drop_as::(); - } - } -} - -// Register the plugin vtable -owlry_plugin! { - info: plugin_info, - providers: plugin_providers, - init: provider_init, - refresh: provider_refresh, - query: provider_query, - drop: provider_drop, -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_ssh_state_new() { - let state = SshState::new(); - assert!(state.items.is_empty()); - } - - #[test] - fn test_parse_simple_config() { - let mut state = SshState::new(); - - // We can't easily test the full flow without mocking file paths, - // but we can test the add_host_item method - state.add_host_item( - "myserver", - Some("192.168.1.100".to_string()), - Some("admin".to_string()), - Some("2222".to_string()), - ); - - assert_eq!(state.items.len(), 1); - assert_eq!(state.items[0].name.as_str(), "SSH: myserver"); - assert!(state.items[0].command.as_str().contains("ssh myserver")); - } - - #[test] - fn test_add_host_without_details() { - let mut state = SshState::new(); - state.add_host_item("simple-host", None, None, None); - - assert_eq!(state.items.len(), 1); - assert_eq!(state.items[0].name.as_str(), "SSH: simple-host"); - assert!(state.items[0].description.is_none()); - } - - #[test] - fn test_add_host_with_partial_details() { - let mut state = SshState::new(); - state.add_host_item("partial", Some("example.com".to_string()), None, None); - - assert_eq!(state.items.len(), 1); - let desc = state.items[0].description.as_ref().unwrap(); - assert_eq!(desc.as_str(), "example.com"); - } - - #[test] - fn test_items_have_icons() { - let mut state = SshState::new(); - state.add_host_item("test", None, None, None); - - assert!(state.items[0].icon.is_some()); - assert_eq!(state.items[0].icon.as_ref().unwrap().as_str(), PROVIDER_ICON); - } - - #[test] - fn test_items_have_keywords() { - let mut state = SshState::new(); - state.add_host_item("test", None, None, None); - - assert!(!state.items[0].keywords.is_empty()); - let keywords: Vec<&str> = state.items[0].keywords.iter().map(|s| s.as_str()).collect(); - assert!(keywords.contains(&"ssh")); - } -} diff --git a/crates/owlry-plugin-system/Cargo.toml b/crates/owlry-plugin-system/Cargo.toml deleted file mode 100644 index df26101..0000000 --- a/crates/owlry-plugin-system/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "owlry-plugin-system" -version = "0.4.10" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "System plugin for owlry - power and session management commands" -keywords = ["owlry", "plugin", "system", "power"] -categories = ["os"] - -[lib] -crate-type = ["cdylib"] # Compile as dynamic library (.so) - -[dependencies] -# Plugin API for owlry -owlry-plugin-api = { path = "../owlry-plugin-api" } - -# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) -abi_stable = "0.11" diff --git a/crates/owlry-plugin-system/src/lib.rs b/crates/owlry-plugin-system/src/lib.rs deleted file mode 100644 index f68e4f7..0000000 --- a/crates/owlry-plugin-system/src/lib.rs +++ /dev/null @@ -1,254 +0,0 @@ -//! System Plugin for Owlry -//! -//! A static provider that provides system power and session management commands. -//! -//! Commands: -//! - Shutdown - Power off the system -//! - Reboot - Restart the system -//! - Reboot into BIOS - Restart into UEFI/BIOS setup -//! - Suspend - Suspend to RAM -//! - Hibernate - Suspend to disk -//! - Lock Screen - Lock the session -//! - Log Out - End the current session - -use abi_stable::std_types::{ROption, RStr, RString, RVec}; -use owlry_plugin_api::{ - owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, - ProviderPosition, API_VERSION, -}; - -// Plugin metadata -const PLUGIN_ID: &str = "system"; -const PLUGIN_NAME: &str = "System"; -const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); -const PLUGIN_DESCRIPTION: &str = "Power and session management commands"; - -// Provider metadata -const PROVIDER_ID: &str = "system"; -const PROVIDER_NAME: &str = "System"; -const PROVIDER_PREFIX: &str = ":sys"; -const PROVIDER_ICON: &str = "system-shutdown"; -const PROVIDER_TYPE_ID: &str = "system"; - -/// System provider state - holds cached items -struct SystemState { - items: Vec, -} - -impl SystemState { - fn new() -> Self { - Self { items: Vec::new() } - } - - fn load_commands(&mut self) { - self.items.clear(); - - // Define system commands - // Format: (id, name, description, icon, command) - let commands: &[(&str, &str, &str, &str, &str)] = &[ - ( - "system:shutdown", - "Shutdown", - "Power off the system", - "system-shutdown", - "systemctl poweroff", - ), - ( - "system:reboot", - "Reboot", - "Restart the system", - "system-reboot", - "systemctl reboot", - ), - ( - "system:reboot-bios", - "Reboot into BIOS", - "Restart into UEFI/BIOS setup", - "system-reboot", - "systemctl reboot --firmware-setup", - ), - ( - "system:suspend", - "Suspend", - "Suspend to RAM", - "system-suspend", - "systemctl suspend", - ), - ( - "system:hibernate", - "Hibernate", - "Suspend to disk", - "system-suspend-hibernate", - "systemctl hibernate", - ), - ( - "system:lock", - "Lock Screen", - "Lock the session", - "system-lock-screen", - "loginctl lock-session", - ), - ( - "system:logout", - "Log Out", - "End the current session", - "system-log-out", - "loginctl terminate-session self", - ), - ]; - - for (id, name, description, icon, command) in commands { - self.items.push( - PluginItem::new(*id, *name, *command) - .with_description(*description) - .with_icon(*icon) - .with_keywords(vec!["power".to_string(), "system".to_string()]), - ); - } - } -} - -// ============================================================================ -// Plugin Interface Implementation -// ============================================================================ - -extern "C" fn plugin_info() -> PluginInfo { - PluginInfo { - id: RString::from(PLUGIN_ID), - name: RString::from(PLUGIN_NAME), - version: RString::from(PLUGIN_VERSION), - description: RString::from(PLUGIN_DESCRIPTION), - api_version: API_VERSION, - } -} - -extern "C" fn plugin_providers() -> RVec { - vec![ProviderInfo { - id: RString::from(PROVIDER_ID), - name: RString::from(PROVIDER_NAME), - prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), - icon: RString::from(PROVIDER_ICON), - provider_type: ProviderKind::Static, - type_id: RString::from(PROVIDER_TYPE_ID), - position: ProviderPosition::Normal, - priority: 0, // Static: use frecency ordering - }] - .into() -} - -extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { - let state = Box::new(SystemState::new()); - ProviderHandle::from_box(state) -} - -extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - let state = unsafe { &mut *(handle.ptr as *mut SystemState) }; - - // Load/reload commands - state.load_commands(); - - // Return items - state.items.to_vec().into() -} - -extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec { - // Static provider - query is handled by the core using cached items - RVec::new() -} - -extern "C" fn provider_drop(handle: ProviderHandle) { - if !handle.ptr.is_null() { - // SAFETY: We created this handle from Box - unsafe { - handle.drop_as::(); - } - } -} - -// Register the plugin vtable -owlry_plugin! { - info: plugin_info, - providers: plugin_providers, - init: provider_init, - refresh: provider_refresh, - query: provider_query, - drop: provider_drop, -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_system_state_new() { - let state = SystemState::new(); - assert!(state.items.is_empty()); - } - - #[test] - fn test_system_commands_loaded() { - let mut state = SystemState::new(); - state.load_commands(); - - assert!(state.items.len() >= 6); - - // Check for specific commands - let names: Vec<&str> = state.items.iter().map(|i| i.name.as_str()).collect(); - assert!(names.contains(&"Shutdown")); - assert!(names.contains(&"Reboot")); - assert!(names.contains(&"Suspend")); - assert!(names.contains(&"Lock Screen")); - assert!(names.contains(&"Log Out")); - } - - #[test] - fn test_reboot_bios_command() { - let mut state = SystemState::new(); - state.load_commands(); - - let bios_cmd = state - .items - .iter() - .find(|i| i.name.as_str() == "Reboot into BIOS") - .expect("Reboot into BIOS should exist"); - - assert_eq!(bios_cmd.command.as_str(), "systemctl reboot --firmware-setup"); - } - - #[test] - fn test_commands_have_icons() { - let mut state = SystemState::new(); - state.load_commands(); - - for item in &state.items { - assert!( - item.icon.is_some(), - "Item '{}' should have an icon", - item.name.as_str() - ); - } - } - - #[test] - fn test_commands_have_descriptions() { - let mut state = SystemState::new(); - state.load_commands(); - - for item in &state.items { - assert!( - item.description.is_some(), - "Item '{}' should have a description", - item.name.as_str() - ); - } - } -} diff --git a/crates/owlry-plugin-systemd/Cargo.toml b/crates/owlry-plugin-systemd/Cargo.toml deleted file mode 100644 index 323af95..0000000 --- a/crates/owlry-plugin-systemd/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "owlry-plugin-systemd" -version = "0.4.10" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "systemd user services plugin for owlry - list and control user-level systemd services" -keywords = ["owlry", "plugin", "systemd", "services"] -categories = ["os"] - -[lib] -crate-type = ["cdylib"] # Compile as dynamic library (.so) - -[dependencies] -# Plugin API for owlry -owlry-plugin-api = { path = "../owlry-plugin-api" } - -# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) -abi_stable = "0.11" diff --git a/crates/owlry-plugin-systemd/src/lib.rs b/crates/owlry-plugin-systemd/src/lib.rs deleted file mode 100644 index 25b0afa..0000000 --- a/crates/owlry-plugin-systemd/src/lib.rs +++ /dev/null @@ -1,457 +0,0 @@ -//! systemd User Services Plugin for Owlry -//! -//! Lists and controls systemd user-level services. -//! Uses `systemctl --user` commands to interact with services. -//! -//! Each service item opens a submenu with actions like: -//! - Start/Stop/Restart/Reload/Kill -//! - Enable/Disable on startup -//! - View status and journal logs - -use abi_stable::std_types::{ROption, RStr, RString, RVec}; -use owlry_plugin_api::{ - owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, - ProviderPosition, API_VERSION, -}; -use std::process::Command; - -// Plugin metadata -const PLUGIN_ID: &str = "systemd"; -const PLUGIN_NAME: &str = "systemd Services"; -const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); -const PLUGIN_DESCRIPTION: &str = "List and control systemd user services"; - -// Provider metadata -const PROVIDER_ID: &str = "systemd"; -const PROVIDER_NAME: &str = "User Units"; -const PROVIDER_PREFIX: &str = ":uuctl"; -const PROVIDER_ICON: &str = "system-run"; -const PROVIDER_TYPE_ID: &str = "uuctl"; - -/// systemd provider state -struct SystemdState { - items: Vec, -} - -impl SystemdState { - fn new() -> Self { - let mut state = Self { items: Vec::new() }; - state.refresh(); - state - } - - fn refresh(&mut self) { - self.items.clear(); - - if !Self::systemctl_available() { - return; - } - - // List all user services (both running and available) - 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); - - // Sort by name - self.items.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str())); - } - - 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; - } - - // Parse systemctl output - handle variable whitespace - // Format: UNIT LOAD ACTIVE SUB DESCRIPTION... - let mut parts = line.split_whitespace(); - - let unit_name = match parts.next() { - Some(u) => u, - None => continue, - }; - - // Skip if not a proper service name - 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(" "); - - // Create a clean display name - let display_name = unit_name - .trim_end_matches(".service") - .replace("app-", "") - .replace("@autostart", "") - .replace("\\x2d", "-"); - - 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) - }; - - // Store service info in the command field as encoded data - // Format: SUBMENU:type_id:data where data is "unit_name:is_active" - let submenu_data = format!("SUBMENU:uuctl:{}:{}", unit_name, is_active); - - let icon = if is_active { - "emblem-ok-symbolic" - } else { - "emblem-pause-symbolic" - }; - - items.push( - PluginItem::new( - format!("systemd:service:{}", unit_name), - display_name, - submenu_data, - ) - .with_description(status_desc) - .with_icon(icon) - .with_keywords(vec!["systemd".to_string(), "service".to_string()]), - ); - } - - items - } -} - -// ============================================================================ -// Submenu Action Generation (exported for core to use) -// ============================================================================ - -/// Generate submenu actions for a given service -/// This function is called by the core when a service is selected -pub fn actions_for_service(unit_name: &str, display_name: &str, is_active: bool) -> Vec { - let mut actions = Vec::new(); - - if is_active { - actions.push( - PluginItem::new( - format!("systemd:restart:{}", unit_name), - "↻ Restart", - format!("systemctl --user restart {}", unit_name), - ) - .with_description(format!("Restart {}", display_name)) - .with_icon("view-refresh") - .with_keywords(vec!["systemd".to_string(), "service".to_string()]), - ); - - actions.push( - PluginItem::new( - format!("systemd:stop:{}", unit_name), - "■ Stop", - format!("systemctl --user stop {}", unit_name), - ) - .with_description(format!("Stop {}", display_name)) - .with_icon("process-stop") - .with_keywords(vec!["systemd".to_string(), "service".to_string()]), - ); - - actions.push( - PluginItem::new( - format!("systemd:reload:{}", unit_name), - "⟳ Reload", - format!("systemctl --user reload {}", unit_name), - ) - .with_description(format!("Reload {} configuration", display_name)) - .with_icon("view-refresh") - .with_keywords(vec!["systemd".to_string(), "service".to_string()]), - ); - - actions.push( - PluginItem::new( - format!("systemd:kill:{}", unit_name), - "✗ Kill", - format!("systemctl --user kill {}", unit_name), - ) - .with_description(format!("Force kill {}", display_name)) - .with_icon("edit-delete") - .with_keywords(vec!["systemd".to_string(), "service".to_string()]), - ); - } else { - actions.push( - PluginItem::new( - format!("systemd:start:{}", unit_name), - "▶ Start", - format!("systemctl --user start {}", unit_name), - ) - .with_description(format!("Start {}", display_name)) - .with_icon("media-playback-start") - .with_keywords(vec!["systemd".to_string(), "service".to_string()]), - ); - } - - // Always available actions - actions.push( - PluginItem::new( - format!("systemd:status:{}", unit_name), - "ℹ Status", - format!("systemctl --user status {}", unit_name), - ) - .with_description(format!("Show {} status", display_name)) - .with_icon("dialog-information") - .with_keywords(vec!["systemd".to_string(), "service".to_string()]) - .with_terminal(true), - ); - - actions.push( - PluginItem::new( - format!("systemd:journal:{}", unit_name), - "📋 Journal", - format!("journalctl --user -u {} -f", unit_name), - ) - .with_description(format!("Show {} logs", display_name)) - .with_icon("utilities-system-monitor") - .with_keywords(vec!["systemd".to_string(), "service".to_string()]) - .with_terminal(true), - ); - - actions.push( - PluginItem::new( - format!("systemd:enable:{}", unit_name), - "⊕ Enable", - format!("systemctl --user enable {}", unit_name), - ) - .with_description(format!("Enable {} on startup", display_name)) - .with_icon("emblem-default") - .with_keywords(vec!["systemd".to_string(), "service".to_string()]), - ); - - actions.push( - PluginItem::new( - format!("systemd:disable:{}", unit_name), - "⊖ Disable", - format!("systemctl --user disable {}", unit_name), - ) - .with_description(format!("Disable {} on startup", display_name)) - .with_icon("emblem-unreadable") - .with_keywords(vec!["systemd".to_string(), "service".to_string()]), - ); - - actions -} - -// ============================================================================ -// Plugin Interface Implementation -// ============================================================================ - -extern "C" fn plugin_info() -> PluginInfo { - PluginInfo { - id: RString::from(PLUGIN_ID), - name: RString::from(PLUGIN_NAME), - version: RString::from(PLUGIN_VERSION), - description: RString::from(PLUGIN_DESCRIPTION), - api_version: API_VERSION, - } -} - -extern "C" fn plugin_providers() -> RVec { - vec![ProviderInfo { - id: RString::from(PROVIDER_ID), - name: RString::from(PROVIDER_NAME), - prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), - icon: RString::from(PROVIDER_ICON), - provider_type: ProviderKind::Static, - type_id: RString::from(PROVIDER_TYPE_ID), - position: ProviderPosition::Normal, - priority: 0, // Static: use frecency ordering - }] - .into() -} - -extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { - let state = Box::new(SystemdState::new()); - ProviderHandle::from_box(state) -} - -extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - let state = unsafe { &mut *(handle.ptr as *mut SystemdState) }; - - state.refresh(); - state.items.clone().into() -} - -extern "C" fn provider_query(_handle: ProviderHandle, query: RStr<'_>) -> RVec { - let query_str = query.as_str(); - - // Handle submenu action requests: ?SUBMENU:unit.service:is_active - if let Some(data) = query_str.strip_prefix("?SUBMENU:") { - // Parse data format: "unit_name:is_active" - let parts: Vec<&str> = data.splitn(2, ':').collect(); - if parts.len() >= 2 { - let unit_name = parts[0]; - let is_active = parts[1] == "true"; - let display_name = unit_name - .trim_end_matches(".service") - .replace("app-", "") - .replace("@autostart", "") - .replace("\\x2d", "-"); - - return actions_for_service(unit_name, &display_name, is_active).into(); - } else if !data.is_empty() { - // Fallback: just unit name, assume not active - let display_name = data - .trim_end_matches(".service") - .replace("app-", "") - .replace("@autostart", "") - .replace("\\x2d", "-"); - return actions_for_service(data, &display_name, false).into(); - } - } - - // Static provider - normal queries not used - RVec::new() -} - -extern "C" fn provider_drop(handle: ProviderHandle) { - if !handle.ptr.is_null() { - // SAFETY: We created this handle from Box - unsafe { - handle.drop_as::(); - } - } -} - -// Register the plugin vtable -owlry_plugin! { - info: plugin_info, - providers: plugin_providers, - init: provider_init, - refresh: provider_refresh, - query: provider_query, - drop: provider_drop, -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_systemctl_output() { - let output = r#" -foo.service loaded active running Foo Service -bar.service loaded inactive dead Bar Service -baz@autostart.service loaded active running Baz App -"#; - let items = SystemdState::parse_systemctl_output(output); - assert_eq!(items.len(), 3); - - // Check first item - assert_eq!(items[0].name.as_str(), "foo"); - assert!(items[0].command.as_str().contains("SUBMENU:uuctl:foo.service:true")); - - // Check second item (inactive) - assert_eq!(items[1].name.as_str(), "bar"); - assert!(items[1].command.as_str().contains("SUBMENU:uuctl:bar.service:false")); - - // Check third item (cleaned name) - assert_eq!(items[2].name.as_str(), "baz"); - } - - #[test] - fn test_actions_for_active_service() { - let actions = actions_for_service("test.service", "Test", true); - - // Active services should have restart, stop, reload, kill + common actions - let action_ids: Vec<_> = actions.iter().map(|a| a.id.as_str()).collect(); - assert!(action_ids.contains(&"systemd:restart:test.service")); - assert!(action_ids.contains(&"systemd:stop:test.service")); - assert!(action_ids.contains(&"systemd:status:test.service")); - assert!(!action_ids.contains(&"systemd:start:test.service")); // Not for active - } - - #[test] - fn test_actions_for_inactive_service() { - let actions = actions_for_service("test.service", "Test", false); - - // Inactive services should have start + common actions - let action_ids: Vec<_> = actions.iter().map(|a| a.id.as_str()).collect(); - assert!(action_ids.contains(&"systemd:start:test.service")); - assert!(action_ids.contains(&"systemd:status:test.service")); - assert!(!action_ids.contains(&"systemd:stop:test.service")); // Not for inactive - } - - #[test] - fn test_terminal_actions() { - let actions = actions_for_service("test.service", "Test", true); - - // Status and journal should have terminal=true - for action in &actions { - let id = action.id.as_str(); - if id.contains(":status:") || id.contains(":journal:") { - assert!(action.terminal, "Action {} should have terminal=true", id); - } - } - } - - #[test] - fn test_submenu_query() { - // Test that provider_query handles ?SUBMENU: queries correctly - let handle = ProviderHandle { ptr: std::ptr::null_mut() }; - - // Query for active service - let query = RStr::from_str("?SUBMENU:test.service:true"); - let actions = provider_query(handle, query); - assert!(!actions.is_empty(), "Should return actions for submenu query"); - - // Should have restart action for active service - let has_restart = actions.iter().any(|a| a.id.as_str().contains(":restart:")); - assert!(has_restart, "Active service should have restart action"); - - // Query for inactive service - let query = RStr::from_str("?SUBMENU:test.service:false"); - let actions = provider_query(handle, query); - assert!(!actions.is_empty(), "Should return actions for submenu query"); - - // Should have start action for inactive service - let has_start = actions.iter().any(|a| a.id.as_str().contains(":start:")); - assert!(has_start, "Inactive service should have start action"); - - // Normal query should return empty - let query = RStr::from_str("some search"); - let actions = provider_query(handle, query); - assert!(actions.is_empty(), "Normal query should return empty"); - } -} diff --git a/crates/owlry-plugin-weather/Cargo.toml b/crates/owlry-plugin-weather/Cargo.toml deleted file mode 100644 index 894478d..0000000 --- a/crates/owlry-plugin-weather/Cargo.toml +++ /dev/null @@ -1,33 +0,0 @@ -[package] -name = "owlry-plugin-weather" -version = "0.4.10" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "Weather widget plugin for owlry - shows current weather with multiple API support" -keywords = ["owlry", "plugin", "weather", "widget"] -categories = ["gui"] - -[lib] -crate-type = ["cdylib"] # Compile as dynamic library (.so) - -[dependencies] -# Plugin API for owlry -owlry-plugin-api = { path = "../owlry-plugin-api" } - -# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) -abi_stable = "0.11" - -# HTTP client for weather API requests -reqwest = { version = "0.13", features = ["blocking", "json"] } - -# JSON parsing for API responses -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -# TOML config parsing -toml = "0.8" - -# XDG directories for cache persistence -dirs = "5.0" diff --git a/crates/owlry-plugin-weather/src/lib.rs b/crates/owlry-plugin-weather/src/lib.rs deleted file mode 100644 index 0f3edc7..0000000 --- a/crates/owlry-plugin-weather/src/lib.rs +++ /dev/null @@ -1,754 +0,0 @@ -//! Weather Widget Plugin for Owlry -//! -//! Shows current weather with support for multiple APIs: -//! - wttr.in (default, no API key required) -//! - OpenWeatherMap (requires API key) -//! - Open-Meteo (no API key required) -//! -//! Weather data is cached for 15 minutes. -//! -//! ## Configuration -//! -//! Configure via `~/.config/owlry/config.toml`: -//! -//! ```toml -//! [plugins.weather] -//! provider = "wttr.in" # or: openweathermap, open-meteo -//! location = "Berlin" # city name or "lat,lon" -//! # api_key = "..." # Required for OpenWeatherMap -//! ``` - -use abi_stable::std_types::{ROption, RStr, RString, RVec}; -use owlry_plugin_api::{ - owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, - ProviderPosition, API_VERSION, -}; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -// Plugin metadata -const PLUGIN_ID: &str = "weather"; -const PLUGIN_NAME: &str = "Weather"; -const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); -const PLUGIN_DESCRIPTION: &str = "Weather widget with multiple API support"; - -// Provider metadata -const PROVIDER_ID: &str = "weather"; -const PROVIDER_NAME: &str = "Weather"; -const PROVIDER_ICON: &str = "weather-clear"; -const PROVIDER_TYPE_ID: &str = "weather"; - -// Timing constants -const CACHE_DURATION_SECS: u64 = 900; // 15 minutes -const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); -const USER_AGENT: &str = "owlry-launcher/0.3"; - -#[derive(Debug, Clone, PartialEq)] -enum WeatherProviderType { - WttrIn, - OpenWeatherMap, - OpenMeteo, -} - -impl std::str::FromStr for WeatherProviderType { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "wttr.in" | "wttr" | "wttrin" => Ok(Self::WttrIn), - "openweathermap" | "owm" => Ok(Self::OpenWeatherMap), - "open-meteo" | "openmeteo" | "meteo" => Ok(Self::OpenMeteo), - _ => Err(format!("Unknown weather provider: {}", s)), - } - } -} - -#[derive(Debug, Clone)] -struct WeatherConfig { - provider: WeatherProviderType, - api_key: Option, - location: String, -} - -impl WeatherConfig { - /// Load config from ~/.config/owlry/config.toml - /// - /// Reads from [plugins.weather] section, with fallback to [providers] for compatibility. - fn load() -> Self { - let config_path = dirs::config_dir() - .map(|d| d.join("owlry").join("config.toml")); - - let config_content = config_path - .and_then(|p| fs::read_to_string(p).ok()); - - if let Some(content) = config_content - && let Ok(toml) = content.parse::() - { - // Try [plugins.weather] first (new format) - if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table()) - && let Some(weather) = plugins.get("weather").and_then(|v| v.as_table()) - { - return Self::from_toml_table(weather); - } - - // Fallback to [providers] section (old format) - if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) { - let provider_str = providers - .get("weather_provider") - .and_then(|v| v.as_str()) - .unwrap_or("wttr.in"); - - let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn); - - let api_key = providers - .get("weather_api_key") - .and_then(|v| v.as_str()) - .map(String::from); - - let location = providers - .get("weather_location") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - return Self { - provider, - api_key, - location, - }; - } - } - - // Default config - Self { - provider: WeatherProviderType::WttrIn, - api_key: None, - location: String::new(), - } - } - - /// Parse config from a TOML table - fn from_toml_table(table: &toml::Table) -> Self { - let provider_str = table - .get("provider") - .and_then(|v| v.as_str()) - .unwrap_or("wttr.in"); - - let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn); - - let api_key = table - .get("api_key") - .and_then(|v| v.as_str()) - .map(String::from); - - let location = table - .get("location") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - Self { - provider, - api_key, - location, - } - } -} - -/// Cached weather data (persisted to disk) -#[derive(Debug, Clone, Serialize, Deserialize)] -struct WeatherData { - temperature: f32, - feels_like: Option, - condition: String, - humidity: Option, - wind_speed: Option, - icon: String, - location: String, -} - -/// Persistent cache structure (saved to ~/.local/share/owlry/weather_cache.json) -#[derive(Debug, Clone, Serialize, Deserialize)] -struct WeatherCache { - last_fetch_epoch: u64, - data: WeatherData, -} - -/// Weather provider state -struct WeatherState { - items: Vec, - config: WeatherConfig, - last_fetch_epoch: u64, - cached_data: Option, -} - -impl WeatherState { - fn new() -> Self { - Self::with_config(WeatherConfig::load()) - } - - fn with_config(config: WeatherConfig) -> Self { - // Load cached weather from disk if available - // This prevents blocking HTTP requests on every app open - let (last_fetch_epoch, cached_data) = Self::load_cache() - .map(|c| (c.last_fetch_epoch, Some(c.data))) - .unwrap_or((0, None)); - - Self { - items: Vec::new(), - config, - last_fetch_epoch, - cached_data, - } - } - - fn data_dir() -> Option { - dirs::data_dir().map(|d| d.join("owlry")) - } - - fn cache_path() -> Option { - Self::data_dir().map(|d| d.join("weather_cache.json")) - } - - fn load_cache() -> Option { - let path = Self::cache_path()?; - let content = fs::read_to_string(&path).ok()?; - serde_json::from_str(&content).ok() - } - - fn save_cache(&self) { - if let (Some(data_dir), Some(cache_path), Some(data)) = - (Self::data_dir(), Self::cache_path(), &self.cached_data) - { - if fs::create_dir_all(&data_dir).is_err() { - return; - } - let cache = WeatherCache { - last_fetch_epoch: self.last_fetch_epoch, - data: data.clone(), - }; - if let Ok(json) = serde_json::to_string_pretty(&cache) { - let _ = fs::write(&cache_path, json); - } - } - } - - fn now_epoch() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - } - - fn is_cache_valid(&self) -> bool { - if self.last_fetch_epoch == 0 { - return false; - } - let now = Self::now_epoch(); - now.saturating_sub(self.last_fetch_epoch) < CACHE_DURATION_SECS - } - - fn refresh(&mut self) { - // Use cache if still valid (works across app restarts) - if self.is_cache_valid() - && let Some(data) = self.cached_data.clone() { - self.generate_items(&data); - return; - } - - // Fetch new data from API - if let Some(data) = self.fetch_weather() { - self.cached_data = Some(data.clone()); - self.last_fetch_epoch = Self::now_epoch(); - self.save_cache(); // Persist to disk for next app open - self.generate_items(&data); - } else { - // On fetch failure, try to use stale cache if available - if let Some(data) = self.cached_data.clone() { - self.generate_items(&data); - } else { - self.items.clear(); - } - } - } - - fn fetch_weather(&self) -> Option { - match self.config.provider { - WeatherProviderType::WttrIn => self.fetch_wttr_in(), - WeatherProviderType::OpenWeatherMap => self.fetch_openweathermap(), - WeatherProviderType::OpenMeteo => self.fetch_open_meteo(), - } - } - - fn fetch_wttr_in(&self) -> Option { - let location = if self.config.location.is_empty() { - String::new() - } else { - self.config.location.clone() - }; - - let url = format!("https://wttr.in/{}?format=j1", location); - - let client = reqwest::blocking::Client::builder() - .timeout(REQUEST_TIMEOUT) - .user_agent(USER_AGENT) - .build() - .ok()?; - - let response = client.get(&url).send().ok()?; - let json: WttrInResponse = response.json().ok()?; - - let current = json.current_condition.first()?; - let nearest = json.nearest_area.first()?; - - let location_name = nearest - .area_name - .first() - .map(|a| a.value.clone()) - .unwrap_or_else(|| "Unknown".to_string()); - - Some(WeatherData { - temperature: current.temp_c.parse().unwrap_or(0.0), - feels_like: current.feels_like_c.parse().ok(), - condition: current - .weather_desc - .first() - .map(|d| d.value.clone()) - .unwrap_or_else(|| "Unknown".to_string()), - humidity: current.humidity.parse().ok(), - wind_speed: current.windspeed_kmph.parse().ok(), - icon: Self::wttr_code_to_icon(¤t.weather_code), - location: location_name, - }) - } - - fn fetch_openweathermap(&self) -> Option { - let api_key = self.config.api_key.as_ref()?; - if self.config.location.is_empty() { - return None; // OWM requires a location - } - - let url = format!( - "https://api.openweathermap.org/data/2.5/weather?q={}&appid={}&units=metric", - self.config.location, api_key - ); - - let client = reqwest::blocking::Client::builder() - .timeout(REQUEST_TIMEOUT) - .build() - .ok()?; - - let response = client.get(&url).send().ok()?; - let json: OpenWeatherMapResponse = response.json().ok()?; - - let weather = json.weather.first()?; - - Some(WeatherData { - temperature: json.main.temp, - feels_like: Some(json.main.feels_like), - condition: weather.description.clone(), - humidity: Some(json.main.humidity), - wind_speed: Some(json.wind.speed * 3.6), // m/s to km/h - icon: Self::owm_icon_to_freedesktop(&weather.icon), - location: json.name, - }) - } - - fn fetch_open_meteo(&self) -> Option { - let (lat, lon, location_name) = self.get_coordinates()?; - - let url = format!( - "https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&timezone=auto", - lat, lon - ); - - let client = reqwest::blocking::Client::builder() - .timeout(REQUEST_TIMEOUT) - .build() - .ok()?; - - let response = client.get(&url).send().ok()?; - let json: OpenMeteoResponse = response.json().ok()?; - - let current = json.current; - - Some(WeatherData { - temperature: current.temperature_2m, - feels_like: None, - condition: Self::wmo_code_to_description(current.weather_code), - humidity: Some(current.relative_humidity_2m as u8), - wind_speed: Some(current.wind_speed_10m), - icon: Self::wmo_code_to_icon(current.weather_code), - location: location_name, - }) - } - - fn get_coordinates(&self) -> Option<(f64, f64, String)> { - let location = &self.config.location; - - // Check if location is already coordinates (lat,lon) - if location.contains(',') { - let parts: Vec<&str> = location.split(',').collect(); - if parts.len() == 2 - && let (Ok(lat), Ok(lon)) = ( - parts[0].trim().parse::(), - parts[1].trim().parse::(), - ) { - return Some((lat, lon, location.clone())); - } - } - - // Use Open-Meteo geocoding API - let url = format!( - "https://geocoding-api.open-meteo.com/v1/search?name={}&count=1", - location - ); - - let client = reqwest::blocking::Client::builder() - .timeout(REQUEST_TIMEOUT) - .build() - .ok()?; - - let response = client.get(&url).send().ok()?; - let json: GeocodingResponse = response.json().ok()?; - - let result = json.results?.into_iter().next()?; - Some((result.latitude, result.longitude, result.name)) - } - - fn wttr_code_to_icon(code: &str) -> String { - match code { - "113" => "weather-clear", - "116" => "weather-few-clouds", - "119" => "weather-overcast", - "122" => "weather-overcast", - "143" | "248" | "260" => "weather-fog", - "176" | "263" | "266" | "293" | "296" | "299" | "302" | "305" | "308" => { - "weather-showers" - } - "179" | "182" | "185" | "227" | "230" | "323" | "326" | "329" | "332" | "335" - | "338" | "350" | "368" | "371" | "374" | "377" => "weather-snow", - "200" | "386" | "389" | "392" | "395" => "weather-storm", - _ => "weather-clear", - } - .to_string() - } - - fn owm_icon_to_freedesktop(icon: &str) -> String { - match icon { - "01d" | "01n" => "weather-clear", - "02d" | "02n" => "weather-few-clouds", - "03d" | "03n" | "04d" | "04n" => "weather-overcast", - "09d" | "09n" | "10d" | "10n" => "weather-showers", - "11d" | "11n" => "weather-storm", - "13d" | "13n" => "weather-snow", - "50d" | "50n" => "weather-fog", - _ => "weather-clear", - } - .to_string() - } - - fn wmo_code_to_description(code: i32) -> String { - match code { - 0 => "Clear sky", - 1 => "Mainly clear", - 2 => "Partly cloudy", - 3 => "Overcast", - 45 | 48 => "Foggy", - 51 | 53 | 55 => "Drizzle", - 61 | 63 | 65 => "Rain", - 66 | 67 => "Freezing rain", - 71 | 73 | 75 | 77 => "Snow", - 80..=82 => "Rain showers", - 85 | 86 => "Snow showers", - 95 | 96 | 99 => "Thunderstorm", - _ => "Unknown", - } - .to_string() - } - - fn wmo_code_to_icon(code: i32) -> String { - match code { - 0 | 1 => "weather-clear", - 2 => "weather-few-clouds", - 3 => "weather-overcast", - 45 | 48 => "weather-fog", - 51 | 53 | 55 | 61 | 63 | 65 | 80 | 81 | 82 => "weather-showers", - 66 | 67 | 71 | 73 | 75 | 77 | 85 | 86 => "weather-snow", - 95 | 96 | 99 => "weather-storm", - _ => "weather-clear", - } - .to_string() - } - - fn icon_to_resource_path(icon: &str) -> String { - let weather_icon = if icon.contains("clear") { - "wi-day-sunny" - } else if icon.contains("few-clouds") { - "wi-day-cloudy" - } else if icon.contains("overcast") || icon.contains("clouds") { - "wi-cloudy" - } else if icon.contains("fog") { - "wi-fog" - } else if icon.contains("showers") || icon.contains("rain") { - "wi-rain" - } else if icon.contains("snow") { - "wi-snow" - } else if icon.contains("storm") { - "wi-thunderstorm" - } else { - "wi-thermometer" - }; - format!("/org/owlry/launcher/icons/weather/{}.svg", weather_icon) - } - - fn generate_items(&mut self, data: &WeatherData) { - self.items.clear(); - - let temp_str = format!("{}°C", data.temperature.round() as i32); - let name = format!("{} {}", temp_str, data.condition); - - let mut details = vec![data.location.clone()]; - if let Some(humidity) = data.humidity { - details.push(format!("Humidity {}%", humidity)); - } - if let Some(wind) = data.wind_speed { - details.push(format!("Wind {} km/h", wind.round() as i32)); - } - if let Some(feels) = data.feels_like - && (feels - data.temperature).abs() > 2.0 { - details.push(format!("Feels like {}°C", feels.round() as i32)); - } - - let encoded_location = data.location.replace(' ', "+"); - let command = format!("xdg-open 'https://wttr.in/{}'", encoded_location); - - self.items.push( - PluginItem::new("weather-current", name, command) - .with_description(details.join(" | ")) - .with_icon(Self::icon_to_resource_path(&data.icon)) - .with_keywords(vec!["weather".to_string(), "widget".to_string()]), - ); - } -} - -// ============================================================================ -// API Response Types -// ============================================================================ - -#[derive(Debug, Deserialize)] -struct WttrInResponse { - current_condition: Vec, - nearest_area: Vec, -} - -#[derive(Debug, Deserialize)] -struct WttrInCurrent { - #[serde(rename = "temp_C")] - temp_c: String, - #[serde(rename = "FeelsLikeC")] - feels_like_c: String, - humidity: String, - #[serde(rename = "weatherCode")] - weather_code: String, - #[serde(rename = "weatherDesc")] - weather_desc: Vec, - #[serde(rename = "windspeedKmph")] - windspeed_kmph: String, -} - -#[derive(Debug, Deserialize)] -struct WttrInValue { - value: String, -} - -#[derive(Debug, Deserialize)] -struct WttrInArea { - #[serde(rename = "areaName")] - area_name: Vec, -} - -#[derive(Debug, Deserialize)] -struct OpenWeatherMapResponse { - main: OwmMain, - weather: Vec, - wind: OwmWind, - name: String, -} - -#[derive(Debug, Deserialize)] -struct OwmMain { - temp: f32, - feels_like: f32, - humidity: u8, -} - -#[derive(Debug, Deserialize)] -struct OwmWeather { - description: String, - icon: String, -} - -#[derive(Debug, Deserialize)] -struct OwmWind { - speed: f32, -} - -#[derive(Debug, Deserialize)] -struct OpenMeteoResponse { - current: OpenMeteoCurrent, -} - -#[derive(Debug, Deserialize)] -struct OpenMeteoCurrent { - temperature_2m: f32, - relative_humidity_2m: f32, - weather_code: i32, - wind_speed_10m: f32, -} - -#[derive(Debug, Deserialize)] -struct GeocodingResponse { - results: Option>, -} - -#[derive(Debug, Deserialize)] -struct GeocodingResult { - name: String, - latitude: f64, - longitude: f64, -} - -// ============================================================================ -// Plugin Interface Implementation -// ============================================================================ - -extern "C" fn plugin_info() -> PluginInfo { - PluginInfo { - id: RString::from(PLUGIN_ID), - name: RString::from(PLUGIN_NAME), - version: RString::from(PLUGIN_VERSION), - description: RString::from(PLUGIN_DESCRIPTION), - api_version: API_VERSION, - } -} - -extern "C" fn plugin_providers() -> RVec { - vec![ProviderInfo { - id: RString::from(PROVIDER_ID), - name: RString::from(PROVIDER_NAME), - prefix: ROption::RNone, - icon: RString::from(PROVIDER_ICON), - provider_type: ProviderKind::Static, - type_id: RString::from(PROVIDER_TYPE_ID), - position: ProviderPosition::Widget, - priority: 12000, // Widget: highest priority - }] - .into() -} - -extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { - let state = Box::new(WeatherState::new()); - ProviderHandle::from_box(state) -} - -extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - let state = unsafe { &mut *(handle.ptr as *mut WeatherState) }; - - state.refresh(); - state.items.clone().into() -} - -extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec { - // Static provider - query not used, return empty - RVec::new() -} - -extern "C" fn provider_drop(handle: ProviderHandle) { - if !handle.ptr.is_null() { - // SAFETY: We created this handle from Box - unsafe { - handle.drop_as::(); - } - } -} - -// Register the plugin vtable -owlry_plugin! { - info: plugin_info, - providers: plugin_providers, - init: provider_init, - refresh: provider_refresh, - query: provider_query, - drop: provider_drop, -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_weather_provider_type_from_str() { - assert_eq!( - "wttr.in".parse::().unwrap(), - WeatherProviderType::WttrIn - ); - assert_eq!( - "owm".parse::().unwrap(), - WeatherProviderType::OpenWeatherMap - ); - assert_eq!( - "open-meteo".parse::().unwrap(), - WeatherProviderType::OpenMeteo - ); - } - - #[test] - fn test_wttr_code_to_icon() { - assert_eq!(WeatherState::wttr_code_to_icon("113"), "weather-clear"); - assert_eq!(WeatherState::wttr_code_to_icon("116"), "weather-few-clouds"); - assert_eq!(WeatherState::wttr_code_to_icon("176"), "weather-showers"); - assert_eq!(WeatherState::wttr_code_to_icon("200"), "weather-storm"); - } - - #[test] - fn test_wmo_code_to_description() { - assert_eq!(WeatherState::wmo_code_to_description(0), "Clear sky"); - assert_eq!(WeatherState::wmo_code_to_description(3), "Overcast"); - assert_eq!(WeatherState::wmo_code_to_description(95), "Thunderstorm"); - } - - #[test] - fn test_icon_to_resource_path() { - assert_eq!( - WeatherState::icon_to_resource_path("weather-clear"), - "/org/owlry/launcher/icons/weather/wi-day-sunny.svg" - ); - } - - #[test] - fn test_cache_validity() { - let state = WeatherState { - items: Vec::new(), - config: WeatherConfig { - provider: WeatherProviderType::WttrIn, - api_key: None, - location: String::new(), - }, - last_fetch_epoch: 0, - cached_data: None, - }; - assert!(!state.is_cache_valid()); - } -} diff --git a/crates/owlry-plugin-websearch/Cargo.toml b/crates/owlry-plugin-websearch/Cargo.toml deleted file mode 100644 index c07b52a..0000000 --- a/crates/owlry-plugin-websearch/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "owlry-plugin-websearch" -version = "0.4.10" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "Web search plugin for owlry - search the web with configurable search engines" -keywords = ["owlry", "plugin", "websearch", "search"] -categories = ["web-programming"] - -[lib] -crate-type = ["cdylib"] # Compile as dynamic library (.so) - -[dependencies] -# Plugin API for owlry -owlry-plugin-api = { path = "../owlry-plugin-api" } - -# ABI-stable types (re-exported from owlry-plugin-api, but needed for RString etc) -abi_stable = "0.11" diff --git a/crates/owlry-plugin-websearch/src/lib.rs b/crates/owlry-plugin-websearch/src/lib.rs deleted file mode 100644 index 66cf00f..0000000 --- a/crates/owlry-plugin-websearch/src/lib.rs +++ /dev/null @@ -1,299 +0,0 @@ -//! Web Search Plugin for Owlry -//! -//! A dynamic provider that opens web searches in the browser. -//! Supports multiple search engines. -//! -//! Examples: -//! - `? rust programming` → Search DuckDuckGo for "rust programming" -//! - `web rust docs` → Search for "rust docs" -//! - `search how to rust` → Search for "how to rust" - -use abi_stable::std_types::{ROption, RStr, RString, RVec}; -use owlry_plugin_api::{ - owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, - ProviderPosition, API_VERSION, -}; - -// Plugin metadata -const PLUGIN_ID: &str = "websearch"; -const PLUGIN_NAME: &str = "Web Search"; -const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); -const PLUGIN_DESCRIPTION: &str = "Search the web with configurable search engines"; - -// Provider metadata -const PROVIDER_ID: &str = "websearch"; -const PROVIDER_NAME: &str = "Web Search"; -const PROVIDER_PREFIX: &str = "?"; -const PROVIDER_ICON: &str = "web-browser"; -const PROVIDER_TYPE_ID: &str = "websearch"; - -/// 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 if not configured -const DEFAULT_ENGINE: &str = "duckduckgo"; - -/// Web search provider state -struct WebSearchState { - /// URL template with {query} placeholder - url_template: String, -} - -impl WebSearchState { - fn new() -> Self { - Self::with_engine(DEFAULT_ENGINE) - } - - fn with_engine(engine_name: &str) -> Self { - let url_template = SEARCH_ENGINES - .iter() - .find(|(name, _)| *name == engine_name.to_lowercase()) - .map(|(_, url)| url.to_string()) - .unwrap_or_else(|| { - // If not a known engine, treat it as a custom URL template - if engine_name.contains("{query}") { - engine_name.to_string() - } else { - // Fall back to default - SEARCH_ENGINES - .iter() - .find(|(name, _)| *name == DEFAULT_ENGINE) - .map(|(_, url)| url.to_string()) - .unwrap() - } - }); - - Self { url_template } - } - - /// 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 if trimmed.to_lowercase().starts_with("web ") { - Some(trimmed[4..].trim()) - } else if trimmed.to_lowercase().starts_with("search ") { - Some(trimmed[7..].trim()) - } else { - // In filter mode, accept raw query - Some(trimmed) - } - } - - /// URL-encode a search query - 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 search URL from a query - 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 return a PluginItem if valid - 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); - - // Use xdg-open to open the browser - let command = format!("xdg-open '{}'", url); - - Some( - PluginItem::new( - format!("websearch:{}", search_term), - format!("Search: {}", search_term), - command, - ) - .with_description("Open in browser") - .with_icon(PROVIDER_ICON) - .with_keywords(vec!["web".to_string(), "search".to_string()]), - ) - } -} - -// ============================================================================ -// Plugin Interface Implementation -// ============================================================================ - -extern "C" fn plugin_info() -> PluginInfo { - PluginInfo { - id: RString::from(PLUGIN_ID), - name: RString::from(PLUGIN_NAME), - version: RString::from(PLUGIN_VERSION), - description: RString::from(PLUGIN_DESCRIPTION), - api_version: API_VERSION, - } -} - -extern "C" fn plugin_providers() -> RVec { - vec![ProviderInfo { - id: RString::from(PROVIDER_ID), - name: RString::from(PROVIDER_NAME), - prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)), - icon: RString::from(PROVIDER_ICON), - provider_type: ProviderKind::Dynamic, - type_id: RString::from(PROVIDER_TYPE_ID), - position: ProviderPosition::Normal, - priority: 9000, // Dynamic: web search - }] - .into() -} - -extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { - // TODO: Read search engine from config when plugin config is available - let state = Box::new(WebSearchState::new()); - ProviderHandle::from_box(state) -} - -extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec { - // Dynamic provider - refresh does nothing - RVec::new() -} - -extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - // SAFETY: We created this handle from Box - let state = unsafe { &*(handle.ptr as *const WebSearchState) }; - - let query_str = query.as_str(); - - match state.evaluate(query_str) { - Some(item) => vec![item].into(), - None => RVec::new(), - } -} - -extern "C" fn provider_drop(handle: ProviderHandle) { - if !handle.ptr.is_null() { - // SAFETY: We created this handle from Box - unsafe { - handle.drop_as::(); - } - } -} - -// Register the plugin vtable -owlry_plugin! { - info: plugin_info, - providers: plugin_providers, - init: provider_init, - refresh: provider_refresh, - query: provider_query, - drop: provider_drop, -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_search_term() { - assert_eq!( - WebSearchState::extract_search_term("? rust programming"), - Some("rust programming") - ); - assert_eq!( - WebSearchState::extract_search_term("?rust"), - Some("rust") - ); - assert_eq!( - WebSearchState::extract_search_term("web rust docs"), - Some("rust docs") - ); - assert_eq!( - WebSearchState::extract_search_term("search how to rust"), - Some("how to rust") - ); - } - - #[test] - fn test_url_encode() { - assert_eq!(WebSearchState::url_encode("hello world"), "hello+world"); - assert_eq!(WebSearchState::url_encode("foo&bar"), "foo%26bar"); - assert_eq!(WebSearchState::url_encode("a=b"), "a%3Db"); - assert_eq!(WebSearchState::url_encode("test?query"), "test%3Fquery"); - } - - #[test] - fn test_build_search_url() { - let state = WebSearchState::with_engine("duckduckgo"); - let url = state.build_search_url("rust programming"); - assert_eq!(url, "https://duckduckgo.com/?q=rust+programming"); - } - - #[test] - fn test_build_search_url_google() { - let state = WebSearchState::with_engine("google"); - let url = state.build_search_url("rust"); - assert_eq!(url, "https://www.google.com/search?q=rust"); - } - - #[test] - fn test_evaluate() { - let state = WebSearchState::new(); - let item = state.evaluate("? rust docs").unwrap(); - assert_eq!(item.name.as_str(), "Search: rust docs"); - assert!(item.command.as_str().contains("xdg-open")); - assert!(item.command.as_str().contains("duckduckgo")); - } - - #[test] - fn test_evaluate_empty() { - let state = WebSearchState::new(); - assert!(state.evaluate("?").is_none()); - assert!(state.evaluate("? ").is_none()); - } - - #[test] - fn test_custom_url_template() { - let state = WebSearchState::with_engine("https://custom.search/q={query}"); - let url = state.build_search_url("test"); - assert_eq!(url, "https://custom.search/q=test"); - } - - #[test] - fn test_fallback_to_default() { - let state = WebSearchState::with_engine("nonexistent"); - let url = state.build_search_url("test"); - assert!(url.contains("duckduckgo")); // Falls back to default - } -} diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md deleted file mode 100644 index 40626c6..0000000 --- a/docs/PLUGINS.md +++ /dev/null @@ -1,318 +0,0 @@ -# Available Plugins - -Owlry's functionality is provided through a modular plugin system. This document describes all available plugins. - -## Plugin Categories - -### Static Providers - -Static providers load their items once at startup (and on manual refresh). They're best for data that doesn't change frequently. - -### Dynamic Providers - -Dynamic providers evaluate queries in real-time. Each keystroke triggers a new query, making them ideal for calculations, searches, and other interactive features. - -### Widget Providers - -Widget providers display persistent information at the top of results (weather, media controls, timers). - ---- - -## Core Plugins - -### owlry-plugin-calculator - -**Type:** Dynamic -**Prefix:** `:calc`, `=`, `calc ` -**Package:** `owlry-plugin-calculator` - -Evaluate mathematical expressions in real-time. - -**Examples:** -``` -= 5 + 3 → 8 -= sqrt(16) → 4 -= sin(pi/2) → 1 -= 2^10 → 1024 -= (1 + 0.05)^12 → 1.7958... -``` - -**Supported operations:** -- Basic: `+`, `-`, `*`, `/`, `^` (power), `%` (modulo) -- Functions: `sin`, `cos`, `tan`, `asin`, `acos`, `atan` -- Functions: `sqrt`, `abs`, `floor`, `ceil`, `round` -- Functions: `ln`, `log`, `log10`, `exp` -- Constants: `pi`, `e` - ---- - -### owlry-plugin-system - -**Type:** Static -**Prefix:** `:sys` -**Package:** `owlry-plugin-system` - -System power and session management commands. - -**Actions:** -| Name | Description | Command | -|------|-------------|---------| -| Shutdown | Power off | `systemctl poweroff` | -| Reboot | Restart | `systemctl reboot` | -| Reboot into BIOS | UEFI setup | `systemctl reboot --firmware-setup` | -| Suspend | Sleep (RAM) | `systemctl suspend` | -| Hibernate | Sleep (disk) | `systemctl hibernate` | -| Lock Screen | Lock session | `loginctl lock-session` | -| Log Out | End session | `loginctl terminate-session self` | - ---- - -### owlry-plugin-ssh - -**Type:** Static -**Prefix:** `:ssh` -**Package:** `owlry-plugin-ssh` - -SSH hosts parsed from `~/.ssh/config`. - -**Features:** -- Parses `Host` entries from SSH config -- Ignores wildcards (`Host *`) -- Opens connections in your configured terminal - ---- - -### owlry-plugin-clipboard - -**Type:** Static -**Prefix:** `:clip` -**Package:** `owlry-plugin-clipboard` -**Dependencies:** `cliphist`, `wl-clipboard` - -Clipboard history integration with cliphist. - -**Features:** -- Shows last 50 clipboard entries -- Previews text content (truncated to 80 chars) -- Select to copy back to clipboard - ---- - -### owlry-plugin-emoji - -**Type:** Static -**Prefix:** `:emoji` -**Package:** `owlry-plugin-emoji` -**Dependencies:** `wl-clipboard` - -400+ searchable emoji with keywords. - -**Examples:** -``` -:emoji heart → ❤️ 💙 💚 💜 ... -:emoji smile → 😀 😃 😄 😁 ... -:emoji fire → 🔥 -``` - ---- - -### owlry-plugin-scripts - -**Type:** Static -**Prefix:** `:script` -**Package:** `owlry-plugin-scripts` - -User scripts from `~/.local/share/owlry/scripts/`. - -**Setup:** -```bash -mkdir -p ~/.local/share/owlry/scripts -cat > ~/.local/share/owlry/scripts/backup.sh << 'EOF' -#!/bin/bash -rsync -av ~/Documents /backup/ -notify-send "Backup complete" -EOF -chmod +x ~/.local/share/owlry/scripts/backup.sh -``` - ---- - -### owlry-plugin-bookmarks - -**Type:** Static -**Prefix:** `:bm` -**Package:** `owlry-plugin-bookmarks` - -Browser bookmarks from Firefox and Chromium-based browsers. - -**Supported browsers:** -- Firefox (reads places.sqlite) -- Google Chrome -- Brave -- Microsoft Edge -- Vivaldi -- Chromium - ---- - -### owlry-plugin-websearch - -**Type:** Dynamic -**Prefix:** `:web`, `?`, `web ` -**Package:** `owlry-plugin-websearch` - -Web search with configurable search engine. - -**Examples:** -``` -? rust programming → Search for "rust programming" -web linux tips → Search for "linux tips" -``` - -**Configuration:** -```toml -[providers] -search_engine = "duckduckgo" # or: google, bing, startpage -# custom_search_url = "https://search.example.com/?q={}" -``` - ---- - -### owlry-plugin-filesearch - -**Type:** Dynamic -**Prefix:** `:file`, `/`, `find ` -**Package:** `owlry-plugin-filesearch` -**Dependencies:** `fd` (recommended) or `mlocate` - -Real-time file search. - -**Examples:** -``` -/ .bashrc → Find files matching ".bashrc" -find config → Find files matching "config" -``` - -**Configuration:** -```toml -[providers] -file_search_max_results = 50 -# file_search_paths = ["/home", "/etc"] # Custom search paths -``` - ---- - -### owlry-plugin-systemd - -**Type:** Static (with submenu) -**Prefix:** `:uuctl` -**Package:** `owlry-plugin-systemd` -**Dependencies:** `systemd` - -User systemd services with action submenus. - -**Features:** -- Lists user services (`systemctl --user`) -- Shows service status (running/stopped/failed) -- Submenu actions: start, stop, restart, enable, disable, status - -**Usage:** -1. Search `:uuctl docker` -2. Select a service -3. Choose action from submenu - ---- - -## Widget Plugins - -### owlry-plugin-weather - -**Type:** Widget (Static) -**Package:** `owlry-plugin-weather` - -Current weather displayed at the top of results. - -**Supported APIs:** -- wttr.in (default, no API key required) -- OpenWeatherMap (requires API key) -- Open-Meteo (no API key required) - -**Note:** Weather configuration is currently embedded in the plugin. Future versions will support runtime configuration. - -**Features:** -- Temperature, condition, humidity, wind speed -- Weather icons from Weather Icons font -- 15-minute cache -- Click to open detailed forecast - ---- - -### owlry-plugin-media - -**Type:** Widget (Static) -**Package:** `owlry-plugin-media` - -MPRIS media player controls. - -**Features:** -- Shows currently playing track -- Artist, title, album art -- Play/pause, next, previous controls -- Works with Spotify, Firefox, VLC, etc. - ---- - -### owlry-plugin-pomodoro - -**Type:** Widget (Static) -**Package:** `owlry-plugin-pomodoro` - -Pomodoro timer with work/break cycles. - -**Features:** -- Configurable work session duration -- Configurable break duration -- Session counter -- Desktop notifications on phase completion -- Persistent state across sessions - -**Controls:** -- Start/Pause timer -- Skip to next phase -- Reset timer and sessions - ---- - -## Bundle Packages - -For convenience, plugins are available in bundle meta-packages: - -| Bundle | Plugins | -|--------|---------| -| `owlry-meta-essentials` | calculator, system, ssh, scripts, bookmarks | -| `owlry-meta-widgets` | weather, media, pomodoro | -| `owlry-meta-tools` | clipboard, emoji, websearch, filesearch, systemd | -| `owlry-meta-full` | All of the above | - -```bash -# Install everything -yay -S owlry-meta-full - -# Or pick a bundle -yay -S owlry-meta-essentials owlry-meta-widgets -``` - ---- - -## Runtime Packages - -For custom user plugins written in Lua or Rune: - -| Package | Description | -|---------|-------------| -| `owlry-lua` | Lua 5.4 runtime for user plugins | -| `owlry-rune` | Rune runtime for user plugins | - -User plugins are placed in `~/.config/owlry/plugins/`. - -See [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) for creating custom plugins. diff --git a/docs/PLUGIN_DEVELOPMENT.md b/docs/PLUGIN_DEVELOPMENT.md deleted file mode 100644 index 604a827..0000000 --- a/docs/PLUGIN_DEVELOPMENT.md +++ /dev/null @@ -1,571 +0,0 @@ -# Plugin Development Guide - -This guide covers creating plugins for Owlry. There are three ways to extend Owlry: - -1. **Native plugins** (Rust) — Best performance, ABI-stable interface -2. **Lua plugins** — Easy scripting, requires `owlry-lua` runtime -3. **Rune plugins** — Safe scripting with Rust-like syntax, requires `owlry-rune` runtime - ---- - -## Quick Start - -### Native Plugin (Rust) - -```bash -# Create a new plugin crate -cargo new --lib owlry-plugin-myplugin -cd owlry-plugin-myplugin -``` - -Edit `Cargo.toml`: -```toml -[package] -name = "owlry-plugin-myplugin" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -owlry-plugin-api = { git = "https://somegit.dev/Owlibou/owlry" } -abi_stable = "0.11" -``` - -Edit `src/lib.rs`: -```rust -use abi_stable::std_types::{ROption, RStr, RString, RVec}; -use owlry_plugin_api::{ - owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, - ProviderKind, ProviderPosition, API_VERSION, -}; - -extern "C" fn plugin_info() -> PluginInfo { - PluginInfo { - id: RString::from("myplugin"), - name: RString::from("My Plugin"), - version: RString::from(env!("CARGO_PKG_VERSION")), - description: RString::from("A custom plugin"), - api_version: API_VERSION, - } -} - -extern "C" fn plugin_providers() -> RVec { - vec![ProviderInfo { - id: RString::from("myplugin"), - name: RString::from("My Plugin"), - prefix: ROption::RSome(RString::from(":my")), - icon: RString::from("application-x-executable"), - provider_type: ProviderKind::Static, - type_id: RString::from("myplugin"), - position: ProviderPosition::Normal, - priority: 0, // Use frecency-based ordering - }].into() -} - -extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle { - ProviderHandle::null() -} - -extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec { - vec![ - PluginItem::new("item-1", "Hello World", "echo 'Hello!'") - .with_description("A greeting") - .with_icon("face-smile"), - ].into() -} - -extern "C" fn provider_query(_handle: ProviderHandle, _query: RStr<'_>) -> RVec { - RVec::new() -} - -extern "C" fn provider_drop(_handle: ProviderHandle) {} - -owlry_plugin! { - info: plugin_info, - providers: plugin_providers, - init: provider_init, - refresh: provider_refresh, - query: provider_query, - drop: provider_drop, -} -``` - -Build and install: -```bash -cargo build --release -sudo cp target/release/libowlry_plugin_myplugin.so /usr/lib/owlry/plugins/ -``` - -### Lua Plugin - -```bash -# Requires owlry-lua runtime -yay -S owlry-lua - -# Create plugin directory -mkdir -p ~/.config/owlry/plugins/my-lua-plugin -``` - -Create `~/.config/owlry/plugins/my-lua-plugin/plugin.toml`: -```toml -[plugin] -id = "my-lua-plugin" -name = "My Lua Plugin" -version = "0.1.0" -description = "A custom Lua plugin" -entry_point = "init.lua" - -[[providers]] -id = "myluaprovider" -name = "My Lua Provider" -prefix = ":mylua" -icon = "application-x-executable" -type = "static" -type_id = "mylua" -``` - -Create `~/.config/owlry/plugins/my-lua-plugin/init.lua`: -```lua -local owlry = require("owlry") - --- Called once at startup for static providers -function refresh() - return { - owlry.item("item-1", "Hello from Lua", "echo 'Hello Lua!'") - :description("A Lua greeting") - :icon("face-smile"), - } -end - --- Called per-keystroke for dynamic providers -function query(q) - return {} -end -``` - ---- - -## Native Plugin API - -### Plugin VTable - -Every native plugin must export a function that returns a vtable: - -```rust -#[repr(C)] -pub struct PluginVTable { - pub info: extern "C" fn() -> PluginInfo, - pub providers: extern "C" fn() -> RVec, - pub provider_init: extern "C" fn(provider_id: RStr<'_>) -> ProviderHandle, - pub provider_refresh: extern "C" fn(handle: ProviderHandle) -> RVec, - pub provider_query: extern "C" fn(handle: ProviderHandle, query: RStr<'_>) -> RVec, - pub provider_drop: extern "C" fn(handle: ProviderHandle), -} -``` - -Use the `owlry_plugin!` macro to generate the export: - -```rust -owlry_plugin! { - info: my_info_fn, - providers: my_providers_fn, - init: my_init_fn, - refresh: my_refresh_fn, - query: my_query_fn, - drop: my_drop_fn, -} -``` - -### PluginInfo - -```rust -pub struct PluginInfo { - pub id: RString, // Unique ID (e.g., "calculator") - pub name: RString, // Display name - pub version: RString, // Semantic version - pub description: RString, // Short description - pub api_version: u32, // Must match API_VERSION -} -``` - -### ProviderInfo - -```rust -pub struct ProviderInfo { - pub id: RString, // Provider ID within plugin - pub name: RString, // Display name - pub prefix: ROption, // Activation prefix (e.g., ":calc") - pub icon: RString, // Default icon name - pub provider_type: ProviderKind, // Static or Dynamic - pub type_id: RString, // Short ID for badges - pub position: ProviderPosition, // Normal or Widget - pub priority: i32, // Result ordering (higher = first) -} - -pub enum ProviderKind { - Static, // Items loaded at startup via refresh() - Dynamic, // Items computed per-query via query() -} - -pub enum ProviderPosition { - Normal, // Standard results (sorted by score/frecency) - Widget, // Displayed at top when query is empty -} -``` - -### PluginItem - -```rust -pub struct PluginItem { - pub id: RString, // Unique item ID - pub name: RString, // Display name - pub description: ROption, // Optional description - pub icon: ROption, // Optional icon - pub command: RString, // Command to execute - pub terminal: bool, // Run in terminal? - pub keywords: RVec, // Search keywords - pub score_boost: i32, // Frecency boost -} - -// Builder pattern -let item = PluginItem::new("id", "Name", "command") - .with_description("Description") - .with_icon("icon-name") - .with_terminal(true) - .with_keywords(vec!["tag1".to_string(), "tag2".to_string()]) - .with_score_boost(100); -``` - -### ProviderHandle - -For stateful providers, use `ProviderHandle` to store state: - -```rust -struct MyState { - items: Vec, - cache: HashMap, -} - -extern "C" fn provider_init(_: RStr<'_>) -> ProviderHandle { - let state = Box::new(MyState { - items: Vec::new(), - cache: HashMap::new(), - }); - ProviderHandle::from_box(state) -} - -extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { - if handle.ptr.is_null() { - return RVec::new(); - } - - let state = unsafe { &mut *(handle.ptr as *mut MyState) }; - state.items = load_items(); - state.items.clone().into() -} - -extern "C" fn provider_drop(handle: ProviderHandle) { - if !handle.ptr.is_null() { - unsafe { handle.drop_as::(); } - } -} -``` - -### Host API - -Plugins can use host-provided functions: - -```rust -use owlry_plugin_api::{notify, notify_with_icon, log_info, log_warn, log_error}; - -// Send notifications -notify("Title", "Body text"); -notify_with_icon("Title", "Body", "dialog-information"); - -// Logging -log_info("Plugin loaded successfully"); -log_warn("Cache miss, fetching data"); -log_error("Failed to connect to API"); -``` - -### Submenu Support - -Plugins can provide submenus for detailed actions: - -```rust -// Return an item that opens a submenu -PluginItem::new( - "service-docker", - "Docker", - "SUBMENU:systemd:docker.service", // Special command format -) - -// Handle submenu query (query starts with "?SUBMENU:") -extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec { - let q = query.as_str(); - - if let Some(data) = q.strip_prefix("?SUBMENU:") { - // Return submenu actions - return vec![ - PluginItem::new("start", "Start", format!("systemctl start {}", data)), - PluginItem::new("stop", "Stop", format!("systemctl stop {}", data)), - ].into(); - } - - RVec::new() -} -``` - ---- - -## Lua Plugin API - -### Plugin Manifest (plugin.toml) - -```toml -[plugin] -id = "my-plugin" -name = "My Plugin" -version = "1.0.0" -description = "Plugin description" -entry_point = "init.lua" -owlry_version = ">=0.4.0" # Optional version constraint - -[permissions] -fs = ["read"] # File system access -http = true # HTTP requests -process = true # Spawn processes - -[[providers]] -id = "provider1" -name = "Provider Name" -prefix = ":prefix" -icon = "icon-name" -type = "static" # or "dynamic" -type_id = "shortid" -``` - -### Lua API - -```lua -local owlry = require("owlry") - --- Create items -local item = owlry.item(id, name, command) - :description("Description") - :icon("icon-name") - :terminal(false) - :keywords({"tag1", "tag2"}) - --- Notifications -owlry.notify("Title", "Body") -owlry.notify_icon("Title", "Body", "icon-name") - --- Logging -owlry.log.info("Message") -owlry.log.warn("Warning") -owlry.log.error("Error") - --- File operations (requires fs permission) -local content = owlry.fs.read("/path/to/file") -local files = owlry.fs.list("/path/to/dir") -local exists = owlry.fs.exists("/path") - --- HTTP requests (requires http permission) -local response = owlry.http.get("https://api.example.com/data") -local json = owlry.json.decode(response) - --- Process execution (requires process permission) -local output = owlry.process.run("ls", {"-la"}) - --- Cache (persistent across sessions) -owlry.cache.set("key", value, ttl_seconds) -local value = owlry.cache.get("key") -``` - -### Provider Functions - -```lua --- Static provider: called once at startup -function refresh() - return { - owlry.item("id1", "Item 1", "command1"), - owlry.item("id2", "Item 2", "command2"), - } -end - --- Dynamic provider: called on each keystroke -function query(q) - if q == "" then - return {} - end - - return { - owlry.item("result", "Result for: " .. q, "echo " .. q), - } -end -``` - ---- - -## Rune Plugin API - -Rune plugins use a Rust-like syntax with memory safety. - -### Plugin Manifest - -```toml -[plugin] -id = "my-rune-plugin" -name = "My Rune Plugin" -version = "1.0.0" -entry_point = "main.rn" - -[[providers]] -id = "runeprovider" -name = "Rune Provider" -type = "static" -``` - -### Rune API - -```rune -use owlry::{Item, log, notify}; - -pub fn refresh() { - let items = []; - - items.push(Item::new("id", "Name", "command") - .description("Description") - .icon("icon-name")); - - items -} - -pub fn query(q) { - if q.is_empty() { - return []; - } - - log::info(`Query: {q}`); - - [Item::new("result", `Result: {q}`, `echo {q}`)] -} -``` - ---- - -## Best Practices - -### Performance - -1. **Static providers**: Do expensive work in `refresh()`, not `items()` -2. **Dynamic providers**: Keep `query()` fast (<50ms) -3. **Cache data**: Use persistent cache for API responses -4. **Lazy loading**: Don't load all items if only a few are needed - -### Error Handling - -```rust -// Native: Return empty vec on error, log the issue -extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec { - match load_data() { - Ok(items) => items.into(), - Err(e) => { - log_error(&format!("Failed to load: {}", e)); - RVec::new() - } - } -} -``` - -```lua --- Lua: Wrap in pcall for safety -function refresh() - local ok, result = pcall(function() - return load_items() - end) - - if not ok then - owlry.log.error("Failed: " .. result) - return {} - end - - return result -end -``` - -### Icons - -Use freedesktop icon names for consistency: -- `application-x-executable` — Generic executable -- `folder` — Directories -- `text-x-generic` — Text files -- `face-smile` — Emoji/reactions -- `system-shutdown` — Power actions -- `network-server` — SSH/network -- `edit-paste` — Clipboard - -### Testing - -```bash -# Build and test native plugin -cargo build --release -p owlry-plugin-myplugin -cargo test -p owlry-plugin-myplugin - -# Install for testing -sudo cp target/release/libowlry_plugin_myplugin.so /usr/lib/owlry/plugins/ - -# Test with verbose logging -RUST_LOG=debug owlry -``` - ---- - -## Publishing to AUR - -### PKGBUILD Template - -```bash -# Maintainer: Your Name -pkgname=owlry-plugin-myplugin -pkgver=0.1.0 -pkgrel=1 -pkgdesc="My custom Owlry plugin" -arch=('x86_64') -url="https://github.com/you/owlry-plugin-myplugin" -license=('GPL-3.0-or-later') -depends=('owlry') -makedepends=('rust' 'cargo') -source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz") -sha256sums=('...') - -build() { - cd "$pkgname-$pkgver" - cargo build --release -} - -package() { - cd "$pkgname-$pkgver" - install -Dm755 "target/release/lib${pkgname//-/_}.so" \ - "$pkgdir/usr/lib/owlry/plugins/lib${pkgname//-/_}.so" -} -``` - ---- - -## Example Plugins - -The owlry repository includes 13 native plugins as reference implementations: - -| Plugin | Type | Highlights | -|--------|------|------------| -| `owlry-plugin-calculator` | Dynamic | Math parsing, expression evaluation | -| `owlry-plugin-weather` | Static/Widget | HTTP API, JSON parsing, caching | -| `owlry-plugin-systemd` | Static | Submenu actions, service management | -| `owlry-plugin-pomodoro` | Static/Widget | State persistence, notifications | -| `owlry-plugin-clipboard` | Static | External process integration | - -Browse the source at `crates/owlry-plugin-*/` for implementation details. diff --git a/justfile b/justfile index dce0f02..fca24cf 100644 --- a/justfile +++ b/justfile @@ -49,15 +49,7 @@ fmt: clean: cargo clean -# Build a specific plugin (when plugins exist) -plugin name: - cargo build -p owlry-plugin-{{name}} --release - -# Build all plugins -plugins: - cargo build --workspace --release --exclude owlry --exclude owlry-core - -# Install locally (core + plugins + runtimes) +# Install locally (core + runtimes) install-local: #!/usr/bin/env bash set -euo pipefail @@ -67,38 +59,17 @@ install-local: cargo build -p owlry --release --no-default-features # Build core daemon cargo build -p owlry-core --release - # Build plugins - cargo build --workspace --release --exclude owlry --exclude owlry-core + # Build runtimes + cargo build -p owlry-lua -p owlry-rune --release echo "Creating directories..." sudo mkdir -p /usr/lib/owlry/plugins sudo mkdir -p /usr/lib/owlry/runtimes - echo "Cleaning up stale files..." - # Remove runtime files that may have ended up in plugins dir (from old installs) - sudo rm -f /usr/lib/owlry/plugins/libowlry_lua.so /usr/lib/owlry/plugins/libowlry_rune.so - # Remove old short-named plugin files (from old AUR packages before naming standardization) - sudo rm -f /usr/lib/owlry/plugins/libbookmarks.so /usr/lib/owlry/plugins/libcalculator.so \ - /usr/lib/owlry/plugins/libclipboard.so /usr/lib/owlry/plugins/libemoji.so \ - /usr/lib/owlry/plugins/libfilesearch.so /usr/lib/owlry/plugins/libmedia.so \ - /usr/lib/owlry/plugins/libpomodoro.so /usr/lib/owlry/plugins/libscripts.so \ - /usr/lib/owlry/plugins/libssh.so /usr/lib/owlry/plugins/libsystem.so \ - /usr/lib/owlry/plugins/libsystemd.so /usr/lib/owlry/plugins/libweather.so \ - /usr/lib/owlry/plugins/libwebsearch.so - echo "Installing binaries..." sudo install -Dm755 target/release/owlry /usr/bin/owlry sudo install -Dm755 target/release/owlry-core /usr/bin/owlry-core - echo "Installing plugins..." - for plugin in target/release/libowlry_plugin_*.so; do - if [ -f "$plugin" ]; then - name=$(basename "$plugin") - sudo install -Dm755 "$plugin" "/usr/lib/owlry/plugins/$name" - echo " → $name" - fi - done - echo "Installing runtimes..." if [ -f "target/release/libowlry_lua.so" ]; then sudo install -Dm755 target/release/libowlry_lua.so /usr/lib/owlry/runtimes/liblua.so @@ -123,13 +94,14 @@ install-local: echo "Installation complete!" echo " - /usr/bin/owlry (UI)" echo " - /usr/bin/owlry-core (daemon)" - echo " - $(ls /usr/lib/owlry/plugins/*.so 2>/dev/null | wc -l) plugins" echo " - $(ls /usr/lib/owlry/runtimes/*.so 2>/dev/null | wc -l) runtimes" echo " - systemd: owlry-core.service, owlry-core.socket" echo "" echo "To start the daemon:" echo " systemctl --user enable --now owlry-core.service" echo " OR add 'exec-once = owlry-core' to your compositor config" + echo "" + echo "Note: Install plugins separately from the owlry-plugins repo." # === Release Management === @@ -157,7 +129,7 @@ show-versions: crate-version crate: @grep '^version' crates/{{crate}}/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/' -# Bump a specific crate version (usage: just bump-crate owlry-plugin-calculator 0.2.0) +# Bump a specific crate version (usage: just bump-crate owlry-core 0.2.0) bump-crate crate new_version: #!/usr/bin/env bash set -euo pipefail @@ -178,23 +150,6 @@ bump-crate crate new_version: git commit -m "chore({{crate}}): bump version to {{new_version}}" echo "{{crate}} bumped to {{new_version}}" -# Bump all plugins to same version (usage: just bump-plugins 0.2.0) -bump-plugins new_version: - #!/usr/bin/env bash - set -euo pipefail - for toml in crates/owlry-plugin-*/Cargo.toml; do - crate=$(basename $(dirname "$toml")) - old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') - if [ "$old" != "{{new_version}}" ]; then - echo "Bumping $crate from $old to {{new_version}}" - sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml" - fi - done - cargo check --workspace - git add crates/owlry-plugin-*/Cargo.toml Cargo.lock - git commit -m "chore(plugins): bump all plugins to {{new_version}}" - echo "All plugins bumped to {{new_version}}" - # Bump meta-packages (no crate, just AUR version) bump-meta new_version: #!/usr/bin/env bash @@ -210,28 +165,11 @@ bump-meta new_version: done echo "Meta-packages bumped to {{new_version}}" -# Bump all crates (core + plugins + runtimes) to same version +# Bump all crates (core UI + daemon + plugin-api + runtimes) to same version bump-all new_version: #!/usr/bin/env bash set -euo pipefail - # Bump core (UI) - toml="crates/owlry/Cargo.toml" - old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') - if [ "$old" != "{{new_version}}" ]; then - echo "Bumping owlry from $old to {{new_version}}" - sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml" - fi - # Bump core daemon - toml="crates/owlry-core/Cargo.toml" - if [ -f "$toml" ]; then - old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') - if [ "$old" != "{{new_version}}" ]; then - echo "Bumping owlry-core from $old to {{new_version}}" - sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml" - fi - fi - # Bump plugins (including plugin-api) - for toml in crates/owlry-plugin-*/Cargo.toml; do + for toml in crates/*/Cargo.toml; do crate=$(basename $(dirname "$toml")) old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') if [ "$old" != "{{new_version}}" ]; then @@ -239,17 +177,6 @@ bump-all new_version: sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml" fi done - # Bump runtimes - for toml in crates/owlry-lua/Cargo.toml crates/owlry-rune/Cargo.toml; do - if [ -f "$toml" ]; then - crate=$(basename $(dirname "$toml")) - old=$(grep '^version' "$toml" | head -1 | sed 's/.*"\(.*\)"/\1/') - if [ "$old" != "{{new_version}}" ]; then - echo "Bumping $crate from $old to {{new_version}}" - sed -i 's/^version = ".*"/version = "{{new_version}}"/' "$toml" - fi - fi - done cargo check --workspace git add crates/*/Cargo.toml Cargo.lock git commit -m "chore: bump all crates to {{new_version}}" @@ -283,7 +210,7 @@ tag: git push origin "v{{version}}" echo "Tag v{{version}} pushed" -# Update AUR package (core) +# Update AUR package (core UI) aur-update: #!/usr/bin/env bash set -euo pipefail @@ -311,7 +238,7 @@ aur-update: echo "AUR package updated. Review changes above." echo "Run 'just aur-publish' to commit and push." -# Publish AUR package (core) +# Publish AUR package (core UI) aur-publish: #!/usr/bin/env bash set -euo pipefail @@ -323,7 +250,7 @@ aur-publish: echo "AUR package v{{version}} published!" -# Test AUR package build locally (core) +# Test AUR package build locally (core UI) aur-test: #!/usr/bin/env bash set -euo pipefail @@ -338,7 +265,7 @@ aur-test: # === AUR Package Management (individual packages) === -# Update a specific AUR package (usage: just aur-update-pkg owlry-plugin-calculator) +# Update a specific AUR package (usage: just aur-update-pkg owlry-core) aur-update-pkg pkg: #!/usr/bin/env bash set -euo pipefail @@ -351,7 +278,7 @@ aur-update-pkg pkg: url="https://somegit.dev/Owlibou/owlry" - # Determine crate version (unified versioning: all crates share same version) + # Determine crate version case "{{pkg}}" in owlry-meta-essentials|owlry-meta-tools|owlry-meta-widgets|owlry-meta-full) # Meta-packages use static versioning (1.0.0), only bump pkgrel for dep changes @@ -376,7 +303,7 @@ aur-update-pkg pkg: sed -i "s/^pkgver=.*/pkgver=$crate_ver/" PKGBUILD sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD - # Update checksums (unified versioning: all packages use same version) + # Update checksums if grep -q "^source=" PKGBUILD; then echo "Updating checksums..." b2sum=$(curl -sL "$url/archive/v$crate_ver.tar.gz" | b2sum | cut -d' ' -f1) @@ -424,38 +351,6 @@ aur-test-pkg pkg: echo "Package built successfully!" ls -lh *.pkg.tar.zst -# Update all plugin AUR packages -aur-update-plugins: - #!/usr/bin/env bash - set -euo pipefail - for dir in aur/owlry-plugin-*/; do - pkg=$(basename "$dir") - echo "=== Updating $pkg ===" - just aur-update-pkg "$pkg" - echo "" - done - -# Publish all plugin AUR packages -aur-publish-plugins: - #!/usr/bin/env bash - set -euo pipefail - for dir in aur/owlry-plugin-*/; do - pkg=$(basename "$dir") - echo "=== Publishing $pkg ===" - just aur-publish-pkg "$pkg" - echo "" - done - -# Publish all meta-packages -aur-publish-meta: - #!/usr/bin/env bash - set -euo pipefail - for pkg in owlry-meta-essentials owlry-meta-tools owlry-meta-widgets owlry-meta-full; do - echo "=== Publishing $pkg ===" - just aur-publish-pkg "$pkg" - done - echo "All meta-packages published!" - # List all AUR packages with their versions aur-status: #!/usr/bin/env bash @@ -473,19 +368,15 @@ aur-status: fi done -# Update ALL AUR packages (core + plugins + runtimes + meta) +# Update ALL AUR packages (core + daemon + runtimes + meta) aur-update-all: #!/usr/bin/env bash set -euo pipefail - echo "=== Updating core ===" + echo "=== Updating core UI ===" just aur-update echo "" - echo "=== Updating plugins ===" - for dir in aur/owlry-plugin-*/; do - pkg=$(basename "$dir") - echo "--- $pkg ---" - just aur-update-pkg "$pkg" - done + echo "=== Updating core daemon ===" + just aur-update-pkg owlry-core echo "" echo "=== Updating runtimes ===" just aur-update-pkg owlry-lua @@ -503,15 +394,11 @@ aur-update-all: aur-publish-all: #!/usr/bin/env bash set -euo pipefail - echo "=== Publishing core ===" + echo "=== Publishing core UI ===" just aur-publish echo "" - echo "=== Publishing plugins ===" - for dir in aur/owlry-plugin-*/; do - pkg=$(basename "$dir") - echo "--- $pkg ---" - just aur-publish-pkg "$pkg" - done + echo "=== Publishing core daemon ===" + just aur-publish-pkg owlry-core echo "" echo "=== Publishing runtimes ===" just aur-publish-pkg owlry-lua @@ -546,39 +433,3 @@ release-core new_version: (bump new_version) echo "" echo "Core release v{{new_version}} prepared!" echo "Review AUR changes, then run 'just aur-publish'" - -# Full release workflow for everything (core + plugins + runtimes) -# Usage: just release-all 0.5.0 0.3.0 -# First arg is core version, second is plugins/runtimes version -release-all core_version plugin_version: - #!/usr/bin/env bash - set -euo pipefail - - echo "=== Bumping versions ===" - just bump {{core_version}} - just bump-all {{plugin_version}} - - echo "" - echo "=== Pushing to origin ===" - git push - - echo "" - echo "=== Creating tag ===" - just tag - - echo "Waiting for tag to propagate..." - sleep 2 - - echo "" - echo "=== Updating all AUR packages ===" - just aur-update-all - - echo "" - echo "==========================================" - echo "Release prepared!" - echo " Core: v{{core_version}}" - echo " Plugins/Runtimes: v{{plugin_version}}" - echo "" - echo "Review changes with 'just aur-status'" - echo "Then publish with 'just aur-publish-all'" - echo "==========================================" From 0c46082b2b5f4a27d3ba663b34e21b9b004fb914 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 13:27:45 +0100 Subject: [PATCH 18/20] docs: update CLAUDE.md for client/daemon architecture --- CLAUDE.md | 411 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5d52a3f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,411 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Development Commands + +```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 + +# Dev build with verbose logging +cargo run -p owlry --features dev-logging + +# Build core without embedded Lua (smaller binary, uses external owlry-lua) +cargo build -p owlry --release --no-default-features +``` + +## Usage Examples + +### Basic Invocation + +The UI client connects to the `owlry-core` daemon via Unix socket IPC. Start the daemon first: + +```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) +``` + +### dmenu Mode + +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 + +# 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 + +``` +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) +``` + +### IPC Protocol + +Communication uses newline-delimited JSON over a Unix domain socket at `$XDG_RUNTIME_DIR/owlry/owlry.sock`. + +**Request types** (`owlry_core::ipc::Request`): + +| 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 | + +**Response types** (`owlry_core::ipc::Response`): + +| Type | Purpose | +|------|---------| +| `Results` | Search results with `Vec` | +| `Providers` | Provider list with `Vec` | +| `SubmenuItems` | Submenu actions for a plugin | +| `Ack` | Success acknowledgement | +| `Error` | Error with message | + +### Core Data Flow + +``` +[owlry UI] [owlry-core daemon] + +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 +``` + +### Provider System + +**Core providers** (in `owlry-core`): +- **Application**: Desktop applications from XDG directories +- **Command**: Shell commands from PATH + +**dmenu provider** (in `owlry` client, local only): +- **Dmenu**: Pipe-based input (dmenu compatibility) + +All other providers are native plugins in the separate `owlry-plugins` repo (`somegit.dev/Owlibou/owlry-plugins`). + +`ProviderManager` (in `owlry-core`) orchestrates providers and handles: +- Fuzzy matching via `SkimMatcherV2` +- Frecency score boosting +- Native plugin loading from `/usr/lib/owlry/plugins/` + +**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). + +### 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.) + +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 +- **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 + +## 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) From 50caa1ff0dff9513f5a91f7a067f7a7fdb104a53 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 13:30:51 +0100 Subject: [PATCH 19/20] fix(owlry-core): make ProviderFilter dynamically accept all plugin types Replace hardcoded list of 13 plugin IDs in ProviderFilter::all() with an accept_all flag. When set, is_active()/is_enabled() return true for any ProviderType, so dynamically loaded plugins are accepted without maintaining a static list. Prefix-based filtering still narrows scope as before, and from_mode_strings() still filters to explicit modes only. --- crates/owlry-core/src/filter.rs | 68 ++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/crates/owlry-core/src/filter.rs b/crates/owlry-core/src/filter.rs index df9d303..26689cb 100644 --- a/crates/owlry-core/src/filter.rs +++ b/crates/owlry-core/src/filter.rs @@ -11,6 +11,10 @@ use crate::providers::ProviderType; pub struct ProviderFilter { enabled: HashSet, active_prefix: Option, + /// When true, `is_active`/`is_enabled` accept any provider type + /// (unless a prefix narrows the scope). Used by `all()` so that + /// dynamically loaded plugins are accepted without being listed. + accept_all: bool, } /// Result of parsing a query for prefix syntax @@ -86,6 +90,7 @@ impl ProviderFilter { let filter = Self { enabled, active_prefix: None, + accept_all: false, }; #[cfg(feature = "dev-logging")] @@ -100,6 +105,7 @@ impl ProviderFilter { Self { enabled: HashSet::from([ProviderType::Application]), active_prefix: None, + accept_all: false, } } @@ -154,6 +160,8 @@ impl ProviderFilter { pub fn is_active(&self, provider: ProviderType) -> bool { if let Some(ref prefix) = self.active_prefix { &provider == prefix + } else if self.accept_all { + true } else { self.enabled.contains(&provider) } @@ -161,7 +169,7 @@ impl ProviderFilter { /// Check if provider is in enabled set (ignoring prefix) pub fn is_enabled(&self, provider: ProviderType) -> bool { - self.enabled.contains(&provider) + self.accept_all || self.enabled.contains(&provider) } /// Get current active prefix if any @@ -358,15 +366,16 @@ impl ProviderFilter { Self { enabled, active_prefix: None, + accept_all: false, } } - /// Create a filter that accepts all providers. + /// Create a filter that accepts all providers, including any + /// dynamically loaded plugin. /// - /// Internally enables Application, Command, and Dmenu. Plugin providers are - /// implicitly accepted because `is_active` will match them when they appear - /// in the enabled set. For a true "pass everything" filter, this also - /// pre-populates common plugin types. + /// Sets `accept_all` so that `is_active`/`is_enabled` return true for + /// every `ProviderType` without maintaining a static list of plugin IDs. + /// Core types are still placed in `enabled` for UI purposes (tab display). /// /// The daemon uses this as the default when no modes are specified. pub fn all() -> Self { @@ -374,17 +383,10 @@ impl ProviderFilter { enabled.insert(ProviderType::Application); enabled.insert(ProviderType::Command); enabled.insert(ProviderType::Dmenu); - // Common plugin types — the daemon typically has all plugins loaded - for id in &[ - "calc", "clipboard", "emoji", "bookmarks", "ssh", "scripts", - "system", "uuctl", "filesearch", "websearch", "weather", - "media", "pomodoro", - ] { - enabled.insert(ProviderType::Plugin(id.to_string())); - } Self { enabled, active_prefix: None, + accept_all: true, } } @@ -513,12 +515,44 @@ mod tests { } #[test] - fn test_all_includes_common_plugins() { + fn test_all_accepts_any_plugin() { let filter = ProviderFilter::all(); + // Known plugins assert!(filter.is_enabled(ProviderType::Plugin("calc".to_string()))); assert!(filter.is_enabled(ProviderType::Plugin("clipboard".to_string()))); - assert!(filter.is_enabled(ProviderType::Plugin("emoji".to_string()))); - assert!(filter.is_enabled(ProviderType::Plugin("weather".to_string()))); + // Arbitrary unknown plugins must also be accepted + assert!(filter.is_enabled(ProviderType::Plugin("some-future-plugin".to_string()))); + assert!(filter.is_enabled(ProviderType::Plugin("custom-user-plugin".to_string()))); + } + + #[test] + fn test_all_is_active_for_any_plugin() { + let filter = ProviderFilter::all(); + assert!(filter.is_active(ProviderType::Application)); + assert!(filter.is_active(ProviderType::Plugin("unknown-plugin".to_string()))); + } + + #[test] + fn test_all_with_prefix_narrows_scope() { + let mut filter = ProviderFilter::all(); + filter.set_prefix(Some(ProviderType::Application)); + // Prefix narrows: only Application passes + assert!(filter.is_active(ProviderType::Application)); + assert!(!filter.is_active(ProviderType::Command)); + assert!(!filter.is_active(ProviderType::Plugin("calc".to_string()))); + } + + #[test] + fn test_explicit_mode_filter_rejects_unknown_plugins() { + let filter = ProviderFilter::from_mode_strings(&[ + "app".to_string(), + "cmd".to_string(), + ]); + assert!(filter.is_active(ProviderType::Application)); + assert!(filter.is_active(ProviderType::Command)); + // Plugins not in the explicit list must be rejected + assert!(!filter.is_active(ProviderType::Plugin("calc".to_string()))); + assert!(!filter.is_active(ProviderType::Plugin("unknown".to_string()))); } #[test] From f5d83f1372b9b6cfd24783e8b62fc69dfaf0c3ae Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 13:37:55 +0100 Subject: [PATCH 20/20] chore: format, fix clippy warnings, bump all crates to 1.0.0 --- Cargo.lock | 10 +- crates/owlry-core/Cargo.toml | 2 +- crates/owlry-core/src/config/mod.rs | 58 +-- crates/owlry-core/src/filter.rs | 63 +++- crates/owlry-core/src/ipc.rs | 16 +- crates/owlry-core/src/paths.rs | 8 +- crates/owlry-core/src/plugins/api/action.rs | 32 +- crates/owlry-core/src/plugins/api/cache.rs | 54 +-- crates/owlry-core/src/plugins/api/hook.rs | 24 +- crates/owlry-core/src/plugins/api/http.rs | 69 ++-- crates/owlry-core/src/plugins/api/math.rs | 42 ++- crates/owlry-core/src/plugins/api/process.rs | 20 +- crates/owlry-core/src/plugins/api/theme.rs | 35 +- crates/owlry-core/src/plugins/api/utils.rs | 58 +-- crates/owlry-core/src/plugins/loader.rs | 15 +- crates/owlry-core/src/plugins/manifest.rs | 27 +- crates/owlry-core/src/plugins/mod.rs | 33 +- .../owlry-core/src/plugins/native_loader.rs | 21 +- crates/owlry-core/src/plugins/registry.rs | 31 +- crates/owlry-core/src/plugins/runtime.rs | 21 +- .../owlry-core/src/plugins/runtime_loader.rs | 32 +- .../owlry-core/src/providers/application.rs | 22 +- crates/owlry-core/src/providers/command.rs | 6 +- .../owlry-core/src/providers/lua_provider.rs | 4 +- crates/owlry-core/src/providers/mod.rs | 106 ++++-- .../src/providers/native_provider.rs | 9 +- crates/owlry-core/src/server.rs | 15 +- crates/owlry-core/tests/ipc_test.rs | 3 +- crates/owlry-core/tests/server_test.rs | 16 +- crates/owlry-lua/Cargo.toml | 2 +- crates/owlry-lua/src/api/provider.rs | 30 +- crates/owlry-lua/src/api/utils.rs | 329 +++++++++++------- crates/owlry-lua/src/lib.rs | 29 +- crates/owlry-lua/src/loader.rs | 50 ++- crates/owlry-lua/src/manifest.rs | 20 +- crates/owlry-lua/src/runtime.rs | 23 +- crates/owlry-plugin-api/Cargo.toml | 2 +- crates/owlry-plugin-api/src/lib.rs | 8 +- crates/owlry-rune/Cargo.toml | 2 +- crates/owlry-rune/src/lib.rs | 22 +- crates/owlry-rune/src/loader.rs | 24 +- crates/owlry-rune/src/manifest.rs | 15 +- crates/owlry-rune/src/runtime.rs | 25 +- crates/owlry/Cargo.toml | 2 +- crates/owlry/src/app.rs | 39 +-- crates/owlry/src/backend.rs | 94 ++--- crates/owlry/src/client.rs | 61 +--- crates/owlry/src/main.rs | 8 +- crates/owlry/src/plugin_commands.rs | 197 ++++++++--- crates/owlry/src/providers/dmenu.rs | 2 +- crates/owlry/src/theme.rs | 15 +- crates/owlry/src/ui/main_window.rs | 117 +++++-- crates/owlry/src/ui/result_row.rs | 10 +- 53 files changed, 1233 insertions(+), 745 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 91ebb4a..a711046 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2417,7 +2417,7 @@ dependencies = [ [[package]] name = "owlry" -version = "0.4.10" +version = "1.0.0" dependencies = [ "chrono", "clap", @@ -2437,7 +2437,7 @@ dependencies = [ [[package]] name = "owlry-core" -version = "0.5.0" +version = "1.0.0" dependencies = [ "chrono", "ctrlc", @@ -2462,7 +2462,7 @@ dependencies = [ [[package]] name = "owlry-lua" -version = "0.4.10" +version = "1.0.0" dependencies = [ "abi_stable", "chrono", @@ -2480,7 +2480,7 @@ dependencies = [ [[package]] name = "owlry-plugin-api" -version = "0.4.10" +version = "1.0.0" dependencies = [ "abi_stable", "serde", @@ -2488,7 +2488,7 @@ dependencies = [ [[package]] name = "owlry-rune" -version = "0.4.10" +version = "1.0.0" dependencies = [ "chrono", "dirs", diff --git a/crates/owlry-core/Cargo.toml b/crates/owlry-core/Cargo.toml index 0dc162d..6223798 100644 --- a/crates/owlry-core/Cargo.toml +++ b/crates/owlry-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "owlry-core" -version = "0.5.0" +version = "1.0.0" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/owlry-core/src/config/mod.rs b/crates/owlry-core/src/config/mod.rs index 1e54096..065e67e 100644 --- a/crates/owlry-core/src/config/mod.rs +++ b/crates/owlry-core/src/config/mod.rs @@ -73,11 +73,7 @@ fn default_max_results() -> usize { } fn default_tabs() -> Vec { - vec![ - "app".to_string(), - "cmd".to_string(), - "uuctl".to_string(), - ] + vec!["app".to_string(), "cmd".to_string(), "uuctl".to_string()] } /// User-customizable theme colors @@ -143,10 +139,18 @@ impl Default for AppearanceConfig { } } -fn default_width() -> i32 { 850 } -fn default_height() -> i32 { 650 } -fn default_font_size() -> u32 { 14 } -fn default_border_radius() -> u32 { 12 } +fn default_width() -> i32 { + 850 +} +fn default_height() -> i32 { + 650 +} +fn default_font_size() -> u32 { + 14 +} +fn default_border_radius() -> u32 { + 12 +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProvidersConfig { @@ -196,7 +200,6 @@ pub struct ProvidersConfig { pub files: bool, // ─── Widget Providers ─────────────────────────────────────────────── - /// Enable MPRIS media player widget #[serde(default = "default_true")] pub media: bool, @@ -350,28 +353,19 @@ impl PluginsConfig { /// Get a string value from a plugin's config #[allow(dead_code)] pub fn get_plugin_string(&self, plugin_name: &str, key: &str) -> Option<&str> { - self.plugin_configs - .get(plugin_name)? - .get(key)? - .as_str() + self.plugin_configs.get(plugin_name)?.get(key)?.as_str() } /// Get an integer value from a plugin's config #[allow(dead_code)] pub fn get_plugin_int(&self, plugin_name: &str, key: &str) -> Option { - self.plugin_configs - .get(plugin_name)? - .get(key)? - .as_integer() + self.plugin_configs.get(plugin_name)?.get(key)?.as_integer() } /// Get a boolean value from a plugin's config #[allow(dead_code)] pub fn get_plugin_bool(&self, plugin_name: &str, key: &str) -> Option { - self.plugin_configs - .get(plugin_name)? - .get(key)? - .as_bool() + self.plugin_configs.get(plugin_name)?.get(key)?.as_bool() } } @@ -414,7 +408,6 @@ fn default_pomodoro_break() -> u32 { 5 } - /// Detect the best available terminal emulator /// Fallback chain: /// 1. $TERMINAL env var (user's explicit preference) @@ -427,10 +420,12 @@ fn default_pomodoro_break() -> u32 { fn detect_terminal() -> String { // 1. Check $TERMINAL env var first (user's explicit preference) if let Ok(term) = std::env::var("TERMINAL") - && !term.is_empty() && command_exists(&term) { - debug!("Using $TERMINAL: {}", term); - return term; - } + && !term.is_empty() + && command_exists(&term) + { + debug!("Using $TERMINAL: {}", term); + return term; + } // 2. Try xdg-terminal-exec (freedesktop standard) if command_exists("xdg-terminal-exec") { @@ -454,7 +449,14 @@ fn detect_terminal() -> String { } // 5. Common X11/legacy terminals - let legacy_terminals = ["gnome-terminal", "konsole", "xfce4-terminal", "mate-terminal", "tilix", "terminator"]; + let legacy_terminals = [ + "gnome-terminal", + "konsole", + "xfce4-terminal", + "mate-terminal", + "tilix", + "terminator", + ]; for term in legacy_terminals { if command_exists(term) { debug!("Found legacy terminal: {}", term); diff --git a/crates/owlry-core/src/filter.rs b/crates/owlry-core/src/filter.rs index 26689cb..9cc160d 100644 --- a/crates/owlry-core/src/filter.rs +++ b/crates/owlry-core/src/filter.rs @@ -94,7 +94,10 @@ impl ProviderFilter { }; #[cfg(feature = "dev-logging")] - debug!("[Filter] Created with enabled providers: {:?}", filter.enabled); + debug!( + "[Filter] Created with enabled providers: {:?}", + filter.enabled + ); filter } @@ -118,13 +121,19 @@ impl ProviderFilter { self.enabled.insert(ProviderType::Application); } #[cfg(feature = "dev-logging")] - debug!("[Filter] Toggled OFF {:?}, enabled: {:?}", provider, self.enabled); + debug!( + "[Filter] Toggled OFF {:?}, enabled: {:?}", + provider, self.enabled + ); } else { #[cfg(feature = "dev-logging")] let provider_debug = format!("{:?}", provider); self.enabled.insert(provider); #[cfg(feature = "dev-logging")] - debug!("[Filter] Toggled ON {}, enabled: {:?}", provider_debug, self.enabled); + debug!( + "[Filter] Toggled ON {}, enabled: {:?}", + provider_debug, self.enabled + ); } } @@ -151,7 +160,10 @@ impl ProviderFilter { pub fn set_prefix(&mut self, prefix: Option) { #[cfg(feature = "dev-logging")] if self.active_prefix != prefix { - debug!("[Filter] Prefix changed: {:?} -> {:?}", self.active_prefix, prefix); + debug!( + "[Filter] Prefix changed: {:?} -> {:?}", + self.active_prefix, prefix + ); } self.active_prefix = prefix; } @@ -190,7 +202,10 @@ impl ProviderFilter { let tag = rest[..space_idx].to_lowercase(); let query_part = rest[space_idx + 1..].to_string(); #[cfg(feature = "dev-logging")] - debug!("[Filter] parse_query({:?}) -> tag={:?}, query={:?}", query, tag, query_part); + debug!( + "[Filter] parse_query({:?}) -> tag={:?}, query={:?}", + query, tag, query_part + ); return ParsedQuery { prefix: None, tag_filter: Some(tag), @@ -245,7 +260,10 @@ impl ProviderFilter { for (prefix_str, provider) in core_prefixes { if let Some(rest) = trimmed.strip_prefix(prefix_str) { #[cfg(feature = "dev-logging")] - debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, provider, rest); + debug!( + "[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", + query, provider, rest + ); return ParsedQuery { prefix: Some(provider.clone()), tag_filter: None, @@ -259,7 +277,10 @@ impl ProviderFilter { if let Some(rest) = trimmed.strip_prefix(prefix_str) { let provider = ProviderType::Plugin(type_id.to_string()); #[cfg(feature = "dev-logging")] - debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, provider, rest); + debug!( + "[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", + query, provider, rest + ); return ParsedQuery { prefix: Some(provider), tag_filter: None, @@ -304,7 +325,10 @@ impl ProviderFilter { for (prefix_str, provider) in partial_core { if trimmed == *prefix_str { #[cfg(feature = "dev-logging")] - debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider); + debug!( + "[Filter] parse_query({:?}) -> partial prefix {:?}", + query, provider + ); return ParsedQuery { prefix: Some(provider.clone()), tag_filter: None, @@ -317,7 +341,10 @@ impl ProviderFilter { if trimmed == *prefix_str { let provider = ProviderType::Plugin(type_id.to_string()); #[cfg(feature = "dev-logging")] - debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider); + debug!( + "[Filter] parse_query({:?}) -> partial prefix {:?}", + query, provider + ); return ParsedQuery { prefix: Some(provider), tag_filter: None, @@ -333,7 +360,10 @@ impl ProviderFilter { }; #[cfg(feature = "dev-logging")] - debug!("[Filter] parse_query({:?}) -> prefix={:?}, tag={:?}, query={:?}", query, result.prefix, result.tag_filter, result.query); + debug!( + "[Filter] parse_query({:?}) -> prefix={:?}, tag={:?}, query={:?}", + query, result.prefix, result.tag_filter, result.query + ); result } @@ -396,7 +426,8 @@ impl ProviderFilter { /// "app"/"apps"/"application" -> Application, "cmd"/"command" -> Command, /// "dmenu" -> Dmenu, and everything else -> Plugin(id). pub fn mode_string_to_provider_type(mode: &str) -> ProviderType { - mode.parse::().unwrap_or_else(|_| ProviderType::Plugin(mode.to_string())) + mode.parse::() + .unwrap_or_else(|_| ProviderType::Plugin(mode.to_string())) } /// Get display name for current mode @@ -452,7 +483,10 @@ mod tests { #[test] fn test_parse_query_plugin_prefix() { let result = ProviderFilter::parse_query(":calc 5+3"); - assert_eq!(result.prefix, Some(ProviderType::Plugin("calc".to_string()))); + assert_eq!( + result.prefix, + Some(ProviderType::Plugin("calc".to_string())) + ); assert_eq!(result.query, "5+3"); } @@ -544,10 +578,7 @@ mod tests { #[test] fn test_explicit_mode_filter_rejects_unknown_plugins() { - let filter = ProviderFilter::from_mode_strings(&[ - "app".to_string(), - "cmd".to_string(), - ]); + let filter = ProviderFilter::from_mode_strings(&["app".to_string(), "cmd".to_string()]); assert!(filter.is_active(ProviderType::Application)); assert!(filter.is_active(ProviderType::Command)); // Plugins not in the explicit list must be rejected diff --git a/crates/owlry-core/src/ipc.rs b/crates/owlry-core/src/ipc.rs index 69deafd..9ac3d16 100644 --- a/crates/owlry-core/src/ipc.rs +++ b/crates/owlry-core/src/ipc.rs @@ -29,19 +29,11 @@ pub enum Request { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Response { - Results { - items: Vec, - }, - Providers { - list: Vec, - }, - SubmenuItems { - items: Vec, - }, + Results { items: Vec }, + Providers { list: Vec }, + SubmenuItems { items: Vec }, Ack, - Error { - message: String, - }, + Error { message: String }, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/owlry-core/src/paths.rs b/crates/owlry-core/src/paths.rs index 2505f37..7da39a7 100644 --- a/crates/owlry-core/src/paths.rs +++ b/crates/owlry-core/src/paths.rs @@ -32,7 +32,6 @@ pub fn cache_home() -> Option { dirs::cache_dir() } - // ============================================================================= // Owlry-specific directories // ============================================================================= @@ -175,9 +174,10 @@ pub fn socket_path() -> PathBuf { /// Ensure parent directory of a file exists pub fn ensure_parent_dir(path: &std::path::Path) -> std::io::Result<()> { if let Some(parent) = path.parent() - && !parent.exists() { - std::fs::create_dir_all(parent)?; - } + && !parent.exists() + { + std::fs::create_dir_all(parent)?; + } Ok(()) } diff --git a/crates/owlry-core/src/plugins/api/action.rs b/crates/owlry-core/src/plugins/api/action.rs index 985f574..ce798d6 100644 --- a/crates/owlry-core/src/plugins/api/action.rs +++ b/crates/owlry-core/src/plugins/api/action.rs @@ -54,9 +54,9 @@ pub fn register_action_api(lua: &Lua, owlry: &Table, plugin_id: &str) -> LuaResu .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"))?; + 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(); @@ -166,7 +166,7 @@ pub fn get_actions_for_item(lua: &Lua, item: &Table) -> LuaResult("filter") { match filter.call::(item.clone()) { - Ok(true) => {} // Include this action + Ok(true) => {} // Include this action Ok(false) => continue, // Skip this action Err(e) => { log::warn!("Action filter failed: {}", e); @@ -220,7 +220,8 @@ mod tests { fn test_action_registration() { let lua = setup_lua("test-plugin"); - let chunk = lua.load(r#" + let chunk = lua.load( + r#" return owlry.action.register({ id = "copy-name", name = "Copy Name", @@ -229,7 +230,8 @@ mod tests { -- copy logic here end }) - "#); + "#, + ); let action_id: String = chunk.call(()).unwrap(); assert_eq!(action_id, "test-plugin:copy-name"); @@ -243,7 +245,8 @@ mod tests { fn test_action_with_filter() { let lua = setup_lua("test-plugin"); - let chunk = lua.load(r#" + let chunk = lua.load( + r#" owlry.action.register({ id = "bookmark-action", name = "Open in Browser", @@ -252,7 +255,8 @@ mod tests { end, handler = function(item) end }) - "#); + "#, + ); chunk.call::<()>(()).unwrap(); // Create bookmark item @@ -276,14 +280,16 @@ mod tests { fn test_action_unregister() { let lua = setup_lua("test-plugin"); - let chunk = lua.load(r#" + 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); @@ -296,7 +302,8 @@ mod tests { let lua = setup_lua("test-plugin"); // Register action that sets a global - let chunk = lua.load(r#" + let chunk = lua.load( + r#" result = nil owlry.action.register({ id = "test-exec", @@ -305,7 +312,8 @@ mod tests { result = item.name end }) - "#); + "#, + ); chunk.call::<()>(()).unwrap(); // Create test item diff --git a/crates/owlry-core/src/plugins/api/cache.rs b/crates/owlry-core/src/plugins/api/cache.rs index 448b066..bcb5a1f 100644 --- a/crates/owlry-core/src/plugins/api/cache.rs +++ b/crates/owlry-core/src/plugins/api/cache.rs @@ -35,9 +35,9 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { 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)) - })?; + 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() { @@ -50,8 +50,10 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { } // 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)))?; + 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 { @@ -75,9 +77,9 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { expires_at, }; - let mut cache = CACHE.lock().map_err(|e| { - mlua::Error::external(format!("Failed to lock cache: {}", e)) - })?; + let mut cache = CACHE + .lock() + .map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?; cache.insert(key, entry); Ok(true) @@ -88,9 +90,9 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { 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)) - })?; + let mut cache = CACHE + .lock() + .map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?; Ok(cache.remove(&key).is_some()) })?, @@ -100,9 +102,9 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { 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 mut cache = CACHE + .lock() + .map_err(|e| mlua::Error::external(format!("Failed to lock cache: {}", e)))?; let count = cache.len(); cache.clear(); @@ -114,9 +116,9 @@ pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { 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)) - })?; + 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()) @@ -249,10 +251,12 @@ mod tests { let _: bool = chunk.call(()).unwrap(); // Get and verify - let chunk = lua.load(r#" + 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); @@ -262,12 +266,14 @@ mod tests { fn test_cache_delete() { let lua = setup_lua(); - let chunk = lua.load(r#" + 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()); @@ -277,12 +283,14 @@ mod tests { fn test_cache_has() { let lua = setup_lua(); - let chunk = lua.load(r#" + 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); diff --git a/crates/owlry-core/src/plugins/api/hook.rs b/crates/owlry-core/src/plugins/api/hook.rs index b660964..067db61 100644 --- a/crates/owlry-core/src/plugins/api/hook.rs +++ b/crates/owlry-core/src/plugins/api/hook.rs @@ -329,13 +329,15 @@ mod tests { clear_all_hooks(); let lua = setup_lua("test-plugin"); - let chunk = lua.load(r#" + 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); @@ -349,11 +351,13 @@ mod tests { clear_all_hooks(); let lua = setup_lua("test-plugin"); - let chunk = lua.load(r#" + 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 @@ -367,11 +371,13 @@ mod tests { clear_all_hooks(); let lua = setup_lua("test-plugin"); - let chunk = lua.load(r#" + 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); @@ -383,14 +389,16 @@ mod tests { clear_all_hooks(); let lua = setup_lua("test-plugin"); - let chunk = lua.load(r#" + 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 diff --git a/crates/owlry-core/src/plugins/api/http.rs b/crates/owlry-core/src/plugins/api/http.rs index 49b7490..1012b49 100644 --- a/crates/owlry-core/src/plugins/api/http.rs +++ b/crates/owlry-core/src/plugins/api/http.rs @@ -26,18 +26,21 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { 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)))?; + .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 Ok(headers) = opts.get::
("headers") + { + for pair in headers.pairs::() { + let (key, value) = pair?; + request = request.header(&key, &value); } + } let response = request .send() @@ -45,9 +48,9 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { 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 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)?; @@ -78,18 +81,21 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { 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)))?; + .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); - } + && 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 { @@ -102,11 +108,7 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { .body(json_str) } Value::Nil => request, - _ => { - return Err(mlua::Error::external( - "POST body must be a string or table", - )) - } + _ => return Err(mlua::Error::external("POST body must be a string or table")), }; let response = request @@ -115,9 +117,9 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { 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 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)?; @@ -149,19 +151,22 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { 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)))?; + .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 Ok(headers) = opts.get::
("headers") + { + for pair in headers.pairs::() { + let (key, value) = pair?; + request = request.header(&key, &value); } + } let response = request .send() @@ -174,9 +179,9 @@ pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { ))); } - let body = response - .text() - .map_err(|e| mlua::Error::external(format!("Failed to read response body: {}", e)))?; + 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) diff --git a/crates/owlry-core/src/plugins/api/math.rs b/crates/owlry-core/src/plugins/api/math.rs index 54a961c..84158c6 100644 --- a/crates/owlry-core/src/plugins/api/math.rs +++ b/crates/owlry-core/src/plugins/api/math.rs @@ -14,20 +14,20 @@ pub fn register_math_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { // 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 meval::eval_str(&expr) { - Ok(result) => { - if result.is_finite() { - Ok((Some(result), None)) - } else { - Ok((None, Some("Result is not a finite number".to_string()))) + lua.create_function( + |_lua, expr: String| -> LuaResult<(Option, Option)> { + match meval::eval_str(&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.to_string()))), } - Err(e) => { - Ok((None, Some(e.to_string()))) - } - } - })?, + }, + )?, )?; // owlry.math.calc(expression) -> number (throws on error) @@ -106,11 +106,13 @@ mod tests { fn test_calculate_basic() { let lua = setup_lua(); - let chunk = lua.load(r#" + 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); } @@ -119,11 +121,13 @@ mod tests { fn test_calculate_complex() { let lua = setup_lua(); - let chunk = lua.load(r#" + 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 } @@ -132,14 +136,16 @@ mod tests { fn test_calculate_error() { let lua = setup_lua(); - let chunk = lua.load(r#" + 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); } diff --git a/crates/owlry-core/src/plugins/api/process.rs b/crates/owlry-core/src/plugins/api/process.rs index b8b5204..aaa69fb 100644 --- a/crates/owlry-core/src/plugins/api/process.rs +++ b/crates/owlry-core/src/plugins/api/process.rs @@ -27,8 +27,14 @@ pub fn register_process_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { .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( + "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())?; @@ -95,9 +101,7 @@ pub fn register_env_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { // owlry.env.get(name) -> string or nil env_table.set( "get", - lua.create_function(|_lua, name: String| { - Ok(std::env::var(&name).ok()) - })?, + lua.create_function(|_lua, name: String| Ok(std::env::var(&name).ok()))?, )?; // owlry.env.get_or(name, default) -> string @@ -166,7 +170,8 @@ mod tests { 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 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); } @@ -190,7 +195,8 @@ mod tests { 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 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"); } diff --git a/crates/owlry-core/src/plugins/api/theme.rs b/crates/owlry-core/src/plugins/api/theme.rs index e500222..5e10cbb 100644 --- a/crates/owlry-core/src/plugins/api/theme.rs +++ b/crates/owlry-core/src/plugins/api/theme.rs @@ -21,7 +21,12 @@ pub struct ThemeRegistration { } /// Register theme APIs -pub fn register_theme_api(lua: &Lua, owlry: &Table, plugin_id: &str, plugin_dir: &Path) -> LuaResult<()> { +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(); @@ -50,9 +55,7 @@ pub fn register_theme_api(lua: &Lua, owlry: &Table, plugin_id: &str, plugin_dir: .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()); + 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") { @@ -197,13 +200,15 @@ mod tests { let temp = TempDir::new().unwrap(); let lua = setup_lua("test-plugin", temp.path()); - let chunk = lua.load(r#" + 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"); @@ -221,12 +226,14 @@ mod tests { let lua = setup_lua("test-plugin", temp.path()); - let chunk = lua.load(r#" + 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"); @@ -240,11 +247,13 @@ mod tests { let temp = TempDir::new().unwrap(); let lua = setup_lua("test-plugin", temp.path()); - let chunk = lua.load(r#" + 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(); @@ -262,10 +271,12 @@ mod tests { let temp = TempDir::new().unwrap(); let lua = setup_lua("test-plugin", temp.path()); - let chunk = lua.load(r#" + 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); diff --git a/crates/owlry-core/src/plugins/api/utils.rs b/crates/owlry-core/src/plugins/api/utils.rs index 2f6df20..9c0a8d0 100644 --- a/crates/owlry-core/src/plugins/api/utils.rs +++ b/crates/owlry-core/src/plugins/api/utils.rs @@ -189,9 +189,10 @@ pub fn register_fs_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult // 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())?))); - } + && 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)), @@ -295,7 +296,8 @@ pub fn register_fs_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult 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() + let is_exec = full_path + .metadata() .map(|m| m.permissions().mode() & 0o111 != 0) .unwrap_or(false); Ok(is_exec) @@ -335,28 +337,24 @@ pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { // 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)?))), - } + 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)?))), - } + 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)?))), })?, )?; @@ -388,13 +386,16 @@ fn lua_to_json(value: &Value) -> Result { .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() + 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))); + && (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) @@ -475,9 +476,13 @@ mod tests { 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.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.warn('warning')") + .call::<()>(()) + .unwrap(); lua.load("owlry.log.error('error')").call::<()>(()).unwrap(); } @@ -485,10 +490,7 @@ mod tests { fn test_path_api() { let (lua, _temp) = create_test_lua(); - let home: String = lua - .load("return owlry.path.home()") - .call(()) - .unwrap(); + let home: String = lua.load("return owlry.path.home()").call(()).unwrap(); assert!(!home.is_empty()); let joined: String = lua diff --git a/crates/owlry-core/src/plugins/loader.rs b/crates/owlry-core/src/plugins/loader.rs index 4a6f0ee..632e39f 100644 --- a/crates/owlry-core/src/plugins/loader.rs +++ b/crates/owlry-core/src/plugins/loader.rs @@ -7,7 +7,7 @@ use mlua::Lua; use super::api; use super::error::{PluginError, PluginResult}; use super::manifest::PluginManifest; -use super::runtime::{create_lua_runtime, load_file, SandboxConfig}; +use super::runtime::{SandboxConfig, create_lua_runtime, load_file}; /// A loaded plugin instance #[derive(Debug)] @@ -94,7 +94,10 @@ impl LoadedPlugin { } /// Call a provider's refresh function - pub fn call_provider_refresh(&self, provider_name: &str) -> PluginResult> { + 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(), @@ -108,7 +111,11 @@ impl LoadedPlugin { /// 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> { + 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(), @@ -138,8 +145,8 @@ impl LoadedPlugin { #[cfg(test)] mod tests { - use super::*; use super::super::manifest::{check_compatibility, discover_plugins}; + use super::*; use std::fs; use std::path::Path; use tempfile::TempDir; diff --git a/crates/owlry-core/src/plugins/manifest.rs b/crates/owlry-core/src/plugins/manifest.rs index 929d6cf..df71e26 100644 --- a/crates/owlry-core/src/plugins/manifest.rs +++ b/crates/owlry-core/src/plugins/manifest.rs @@ -112,11 +112,16 @@ pub struct PluginPermissions { /// Discover all plugins in a directory /// /// Returns a map of plugin ID -> (manifest, path) -pub fn discover_plugins(plugins_dir: &Path) -> PluginResult> { +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()); + log::debug!( + "Plugins directory does not exist: {}", + plugins_dir.display() + ); return Ok(plugins); } @@ -143,7 +148,11 @@ pub fn discover_plugins(plugins_dir: &Path) -> PluginResult { @@ -204,7 +213,12 @@ impl PluginManifest { }); } - if !self.plugin.id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { + 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(), @@ -223,7 +237,10 @@ impl PluginManifest { 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), + message: format!( + "Invalid owlry_version constraint: {}", + self.plugin.owlry_version + ), }); } diff --git a/crates/owlry-core/src/plugins/mod.rs b/crates/owlry-core/src/plugins/mod.rs index cbc64e2..ebaf17f 100644 --- a/crates/owlry-core/src/plugins/mod.rs +++ b/crates/owlry-core/src/plugins/mod.rs @@ -50,7 +50,7 @@ pub use loader::LoadedPlugin; // Used by plugins/commands.rs for plugin CLI commands #[allow(unused_imports)] -pub use manifest::{check_compatibility, discover_plugins, PluginManifest}; +pub use manifest::{PluginManifest, check_compatibility, discover_plugins}; // ============================================================================ // Lua Plugin Manager (only available with lua feature) @@ -64,7 +64,7 @@ mod lua_manager { use std::path::PathBuf; use std::rc::Rc; - use manifest::{discover_plugins, check_compatibility}; + use manifest::{check_compatibility, discover_plugins}; /// Plugin manager coordinates loading, initialization, and lifecycle of Lua plugins pub struct PluginManager { @@ -158,7 +158,10 @@ mod lua_manager { /// Get all enabled plugins pub fn enabled_plugins(&self) -> impl Iterator>> + '_ { - self.plugins.values().filter(|p| p.borrow().enabled).cloned() + self.plugins + .values() + .filter(|p| p.borrow().enabled) + .cloned() } /// Get the number of loaded plugins @@ -176,7 +179,10 @@ mod lua_manager { /// 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 plugin_rc = self + .plugins + .get(id) + .ok_or_else(|| PluginError::NotFound(id.to_string()))?; let mut plugin = plugin_rc.borrow_mut(); if !plugin.enabled { @@ -191,7 +197,10 @@ mod lua_manager { /// 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()))?; + let plugin_rc = self + .plugins + .get(id) + .ok_or_else(|| PluginError::NotFound(id.to_string()))?; plugin_rc.borrow_mut().enabled = false; Ok(()) } @@ -200,7 +209,13 @@ mod lua_manager { #[allow(dead_code)] pub fn providers_for(&self, provider_name: &str) -> Vec { self.enabled_plugins() - .filter(|p| p.borrow().manifest.provides.providers.contains(&provider_name.to_string())) + .filter(|p| { + p.borrow() + .manifest + .provides + .providers + .contains(&provider_name.to_string()) + }) .map(|p| p.borrow().id().to_string()) .collect() } @@ -208,13 +223,15 @@ mod lua_manager { /// Check if any plugin provides actions #[allow(dead_code)] pub fn has_action_plugins(&self) -> bool { - self.enabled_plugins().any(|p| p.borrow().manifest.provides.actions) + self.enabled_plugins() + .any(|p| p.borrow().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.borrow().manifest.provides.hooks) + self.enabled_plugins() + .any(|p| p.borrow().manifest.provides.hooks) } /// Get all theme names provided by plugins diff --git a/crates/owlry-core/src/plugins/native_loader.rs b/crates/owlry-core/src/plugins/native_loader.rs index 05d539d..372bc98 100644 --- a/crates/owlry-core/src/plugins/native_loader.rs +++ b/crates/owlry-core/src/plugins/native_loader.rs @@ -17,8 +17,8 @@ use std::sync::{Arc, Once}; use libloading::Library; use log::{debug, error, info, warn}; use owlry_plugin_api::{ - HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo, ProviderKind, - RStr, API_VERSION, + API_VERSION, HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo, + ProviderKind, RStr, }; use crate::notify; @@ -28,9 +28,18 @@ use crate::notify; // ============================================================================ /// Host notification handler -extern "C" fn host_notify(summary: RStr<'_>, body: RStr<'_>, icon: RStr<'_>, urgency: NotifyUrgency) { +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 icon_opt = if icon_str.is_empty() { + None + } else { + Some(icon_str) + }; let notify_urgency = match urgency { NotifyUrgency::Low => notify::NotifyUrgency::Low, @@ -121,7 +130,9 @@ impl NativePlugin { handle: ProviderHandle, query: &str, ) -> Vec { - (self.vtable.provider_query)(handle, query.into()).into_iter().collect() + (self.vtable.provider_query)(handle, query.into()) + .into_iter() + .collect() } /// Drop a provider handle diff --git a/crates/owlry-core/src/plugins/registry.rs b/crates/owlry-core/src/plugins/registry.rs index 42c6798..2d25306 100644 --- a/crates/owlry-core/src/plugins/registry.rs +++ b/crates/owlry-core/src/plugins/registry.rs @@ -110,9 +110,10 @@ impl RegistryClient { 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; - } + && let Ok(elapsed) = SystemTime::now().duration_since(modified) + { + return elapsed < CACHE_DURATION; + } false } @@ -120,11 +121,13 @@ impl RegistryClient { /// 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() + 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); - } + && let Ok(index) = toml::from_str(&content) + { + return Ok(index); + } // Fetch from network self.fetch_from_network() @@ -134,12 +137,7 @@ impl RegistryClient { 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, - ]) + .args(["-fsSL", "--max-time", "30", &self.registry_url]) .output() .map_err(|e| format!("Failed to run curl: {}", e))?; @@ -185,7 +183,9 @@ impl RegistryClient { 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)) + || p.tags + .iter() + .any(|t| t.to_lowercase().contains(&query_lower)) }) .collect(); @@ -210,8 +210,7 @@ impl RegistryClient { 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))?; + fs::remove_file(&cache_path).map_err(|e| format!("Failed to remove cache: {}", e))?; } Ok(()) } diff --git a/crates/owlry-core/src/plugins/runtime.rs b/crates/owlry-core/src/plugins/runtime.rs index da98dbe..00ebf57 100644 --- a/crates/owlry-core/src/plugins/runtime.rs +++ b/crates/owlry-core/src/plugins/runtime.rs @@ -26,7 +26,7 @@ impl Default for SandboxConfig { allow_commands: false, allow_network: false, allow_external_fs: false, - max_run_time_ms: 5000, // 5 seconds + max_run_time_ms: 5000, // 5 seconds max_memory: 64 * 1024 * 1024, // 64 MB } } @@ -49,11 +49,7 @@ 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 libs = StdLib::COROUTINE | StdLib::TABLE | StdLib::STRING | StdLib::UTF8 | StdLib::MATH; let lua = Lua::new_with(libs, mlua::LuaOptions::default())?; @@ -75,9 +71,15 @@ fn setup_safe_globals(lua: &Lua) -> LuaResult<()> { // 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( + "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( + "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)?; @@ -107,8 +109,7 @@ fn os_time(_lua: &Lua, _args: ()) -> LuaResult { /// 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)?; + 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()? diff --git a/crates/owlry-core/src/plugins/runtime_loader.rs b/crates/owlry-core/src/plugins/runtime_loader.rs index de62fcd..3f91ea4 100644 --- a/crates/owlry-core/src/plugins/runtime_loader.rs +++ b/crates/owlry-core/src/plugins/runtime_loader.rs @@ -59,7 +59,11 @@ pub struct ScriptRuntimeVTable { pub init: extern "C" fn(plugins_dir: 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 query: extern "C" fn( + handle: RuntimeHandle, + provider_id: RStr<'_>, + query: RStr<'_>, + ) -> RVec, pub drop: extern "C" fn(handle: RuntimeHandle), } @@ -100,9 +104,8 @@ impl LoadedRuntime { } // 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 = unsafe { Library::new(library_path) } + .map_err(|e| PluginError::LoadError(format!("{}: {}", library_path.display(), e)))?; let library = Arc::new(library); @@ -152,12 +155,8 @@ impl LoadedRuntime { self.providers .iter() .map(|info| { - let provider = RuntimeProvider::new( - self.name, - self.vtable, - self.handle, - info.clone(), - ); + let provider = + RuntimeProvider::new(self.name, self.vtable, self.handle, info.clone()); Box::new(provider) as Box }) .collect() @@ -227,7 +226,10 @@ impl Provider for RuntimeProvider { let name_rstr = RStr::from_str(self.info.name.as_str()); let items_rvec = (self.vtable.refresh)(self.handle, name_rstr); - self.items = items_rvec.into_iter().map(|i| self.convert_item(i)).collect(); + self.items = items_rvec + .into_iter() + .map(|i| self.convert_item(i)) + .collect(); log::debug!( "[RuntimeProvider] '{}' refreshed with {} items", @@ -246,12 +248,16 @@ unsafe impl Send for RuntimeProvider {} /// Check if the Lua runtime is available pub fn lua_runtime_available() -> bool { - PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so").exists() + 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() + PathBuf::from(SYSTEM_RUNTIMES_DIR) + .join("librune.so") + .exists() } impl LoadedRuntime { diff --git a/crates/owlry-core/src/providers/application.rs b/crates/owlry-core/src/providers/application.rs index 3236e64..4886dab 100644 --- a/crates/owlry-core/src/providers/application.rs +++ b/crates/owlry-core/src/providers/application.rs @@ -66,13 +66,14 @@ fn clean_desktop_exec_field(cmd: &str) -> String { cleaned } +#[derive(Default)] pub struct ApplicationProvider { items: Vec, } impl ApplicationProvider { pub fn new() -> Self { - Self { items: Vec::new() } + Self::default() } fn get_application_dirs() -> Vec { @@ -139,15 +140,18 @@ impl Provider for ApplicationProvider { if !current_desktops.is_empty() { // OnlyShowIn: if set, current desktop must be in the list if desktop_entry.only_show_in().is_some_and(|only| { - !current_desktops.iter().any(|de| only.contains(&de.as_str())) + !current_desktops + .iter() + .any(|de| only.contains(&de.as_str())) }) { continue; } // NotShowIn: if current desktop is in the list, skip - if desktop_entry.not_show_in().is_some_and(|not| { - current_desktops.iter().any(|de| not.contains(&de.as_str())) - }) { + if desktop_entry + .not_show_in() + .is_some_and(|not| current_desktops.iter().any(|de| not.contains(&de.as_str()))) + { continue; } } @@ -197,7 +201,8 @@ impl Provider for ApplicationProvider { ); // Sort alphabetically by name - self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + self.items + .sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); } fn items(&self) -> &[LaunchItem] { @@ -219,7 +224,10 @@ mod tests { #[test] fn test_clean_desktop_exec_multiple_placeholders() { assert_eq!(clean_desktop_exec_field("app %f %u %U"), "app"); - assert_eq!(clean_desktop_exec_field("app --flag %u --other"), "app --flag --other"); + assert_eq!( + clean_desktop_exec_field("app --flag %u --other"), + "app --flag --other" + ); } #[test] diff --git a/crates/owlry-core/src/providers/command.rs b/crates/owlry-core/src/providers/command.rs index 0df024f..6fa15fa 100644 --- a/crates/owlry-core/src/providers/command.rs +++ b/crates/owlry-core/src/providers/command.rs @@ -4,13 +4,14 @@ use std::collections::HashSet; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; +#[derive(Default)] pub struct CommandProvider { items: Vec, } impl CommandProvider { pub fn new() -> Self { - Self { items: Vec::new() } + Self::default() } fn get_path_dirs() -> Vec { @@ -97,7 +98,8 @@ impl Provider for CommandProvider { debug!("Found {} commands in PATH", self.items.len()); // Sort alphabetically - self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + self.items + .sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); } fn items(&self) -> &[LaunchItem] { diff --git a/crates/owlry-core/src/providers/lua_provider.rs b/crates/owlry-core/src/providers/lua_provider.rs index d624846..675fcb8 100644 --- a/crates/owlry-core/src/providers/lua_provider.rs +++ b/crates/owlry-core/src/providers/lua_provider.rs @@ -95,9 +95,7 @@ impl Provider for LuaProvider { unsafe impl Send for LuaProvider {} /// Create LuaProviders from all registered providers in a plugin -pub fn create_providers_from_plugin( - plugin: Rc>, -) -> Vec> { +pub fn create_providers_from_plugin(plugin: Rc>) -> Vec> { let registrations = { let p = plugin.borrow(); match p.get_provider_registrations() { diff --git a/crates/owlry-core/src/providers/mod.rs b/crates/owlry-core/src/providers/mod.rs index 9aef46e..4e936d0 100644 --- a/crates/owlry-core/src/providers/mod.rs +++ b/crates/owlry-core/src/providers/mod.rs @@ -141,13 +141,25 @@ impl ProviderManager { let type_id = provider.type_id(); if provider.is_dynamic() { - info!("Registered dynamic provider: {} ({})", provider.name(), type_id); + 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); + info!( + "Registered widget provider: {} ({})", + provider.name(), + type_id + ); manager.widget_providers.push(provider); } else { - info!("Registered static provider: {} ({})", provider.name(), type_id); + info!( + "Registered static provider: {} ({})", + provider.name(), + type_id + ); manager.static_native_providers.push(provider); } } @@ -263,15 +275,25 @@ impl ProviderManager { /// 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) { + 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) { + 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) + self.dynamic_providers + .iter() + .find(|p| p.type_id() == type_id) } /// Execute a plugin action command @@ -311,27 +333,31 @@ impl ProviderManager { /// 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())) + 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() + return self + .all_static_items() .take(max_results) .map(|item| (item.clone(), 0)) .collect(); } - let mut results: Vec<(LaunchItem, i64)> = self.all_static_items() + let mut results: Vec<(LaunchItem, i64)> = self + .all_static_items() .filter_map(|item| { // Match against name and description let name_score = self.matcher.fuzzy_match(&item.name, query); - let desc_score = item.description + let desc_score = item + .description .as_ref() .and_then(|d| self.matcher.fuzzy_match(d, query)); @@ -417,7 +443,10 @@ impl ProviderManager { tag_filter: Option<&str>, ) -> Vec<(LaunchItem, i64)> { #[cfg(feature = "dev-logging")] - debug!("[Search] query={:?}, max={}, frecency_weight={}", query, max_results, frecency_weight); + debug!( + "[Search] query={:?}, max={}, frecency_weight={}", + query, max_results, frecency_weight + ); let mut results: Vec<(LaunchItem, i64)> = Vec::new(); @@ -567,7 +596,13 @@ impl ProviderManager { { 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); + debug!( + "[Search] #{}: {} (score={}, provider={:?})", + i + 1, + item.name, + score, + item.provider + ); } if results.len() > 5 { debug!("[Search] ... and {} more", results.len() - 5); @@ -583,7 +618,11 @@ impl ProviderManager { self.providers .iter() .map(|p| p.provider_type()) - .chain(self.static_native_providers.iter().map(|p| p.provider_type())) + .chain( + self.static_native_providers + .iter() + .map(|p| p.provider_type()), + ) .collect() } @@ -606,16 +645,10 @@ impl ProviderManager { Some(":cmd".to_string()), "utilities-terminal".to_string(), ), - 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".to_string()) + } + ProviderType::Plugin(type_id) => (type_id, None, "application-x-addon".to_string()), }; descs.push(ProviderDescriptor { id, @@ -771,7 +804,10 @@ impl ProviderManager { } #[cfg(feature = "dev-logging")] - debug!("[Submenu] No submenu actions found for plugin '{}'", plugin_id); + debug!( + "[Submenu] No submenu actions found for plugin '{}'", + plugin_id + ); None } @@ -856,9 +892,8 @@ mod tests { #[test] fn test_available_providers_dmenu() { - let providers: Vec> = vec![ - Box::new(MockProvider::new("dmenu", ProviderType::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); @@ -895,9 +930,10 @@ mod tests { #[test] fn test_refresh_provider_unknown_does_not_panic() { - let providers: Vec> = vec![ - Box::new(MockProvider::new("Applications", ProviderType::Application)), - ]; + let providers: Vec> = vec![Box::new(MockProvider::new( + "Applications", + ProviderType::Application, + ))]; let mut pm = ProviderManager::new(providers, Vec::new()); pm.refresh_provider("nonexistent"); // Should complete without panicking @@ -909,8 +945,8 @@ mod tests { make_item("firefox", "Firefox", ProviderType::Application), make_item("vim", "Vim", ProviderType::Application), ]; - let provider = MockProvider::new("Applications", ProviderType::Application) - .with_items(items); + let provider = + MockProvider::new("Applications", ProviderType::Application).with_items(items); let providers: Vec> = vec![Box::new(provider)]; let pm = ProviderManager::new(providers, Vec::new()); diff --git a/crates/owlry-core/src/providers/native_provider.rs b/crates/owlry-core/src/providers/native_provider.rs index 3aabe9b..20aa427 100644 --- a/crates/owlry-core/src/providers/native_provider.rs +++ b/crates/owlry-core/src/providers/native_provider.rs @@ -9,7 +9,9 @@ use std::sync::{Arc, RwLock}; use log::debug; -use owlry_plugin_api::{PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition}; +use owlry_plugin_api::{ + PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition, +}; use super::{LaunchItem, Provider, ProviderType}; use crate::plugins::native_loader::NativePlugin; @@ -76,7 +78,10 @@ impl NativeProvider { } let api_items = self.plugin.query_provider(self.handle, query); - api_items.into_iter().map(|item| self.convert_item(item)).collect() + api_items + .into_iter() + .map(|item| self.convert_item(item)) + .collect() } /// Check if this provider has a prefix that matches the query diff --git a/crates/owlry-core/src/server.rs b/crates/owlry-core/src/server.rs index 2ac12a1..f345379 100644 --- a/crates/owlry-core/src/server.rs +++ b/crates/owlry-core/src/server.rs @@ -141,8 +141,14 @@ impl Server { let pm_guard = pm.lock().unwrap(); let frecency_guard = frecency.lock().unwrap(); - let results = - pm_guard.search_with_frecency(text, max, &filter, &frecency_guard, weight, None); + let results = pm_guard.search_with_frecency( + text, + max, + &filter, + &frecency_guard, + weight, + None, + ); Response::Results { items: results @@ -152,7 +158,10 @@ impl Server { } } - Request::Launch { item_id, provider: _ } => { + Request::Launch { + item_id, + provider: _, + } => { let mut frecency_guard = frecency.lock().unwrap(); frecency_guard.record_launch(item_id); Response::Ack diff --git a/crates/owlry-core/tests/ipc_test.rs b/crates/owlry-core/tests/ipc_test.rs index 6598fbf..8b0bf3d 100644 --- a/crates/owlry-core/tests/ipc_test.rs +++ b/crates/owlry-core/tests/ipc_test.rs @@ -122,7 +122,8 @@ fn test_plugin_action_request() { #[test] fn test_terminal_field_defaults_false() { // terminal field should default to false when missing from JSON - let json = r#"{"id":"test","title":"Test","description":"","icon":"","provider":"cmd","score":0}"#; + let json = + r#"{"id":"test","title":"Test","description":"","icon":"","provider":"cmd","score":0}"#; let item: ResultItem = serde_json::from_str(json).unwrap(); assert!(!item.terminal); } diff --git a/crates/owlry-core/tests/server_test.rs b/crates/owlry-core/tests/server_test.rs index 73b7e26..b80ee13 100644 --- a/crates/owlry-core/tests/server_test.rs +++ b/crates/owlry-core/tests/server_test.rs @@ -37,7 +37,11 @@ fn test_server_responds_to_providers_request() { match resp { Response::Providers { list } => { // The default ProviderManager always has at least Application and Command - assert!(list.len() >= 2, "expected at least 2 providers, got {}", list.len()); + assert!( + list.len() >= 2, + "expected at least 2 providers, got {}", + list.len() + ); let ids: Vec<&str> = list.iter().map(|p| p.id.as_str()).collect(); assert!(ids.contains(&"app"), "missing 'app' provider"); assert!(ids.contains(&"cmd"), "missing 'cmd' provider"); @@ -95,7 +99,10 @@ fn test_server_handles_query_request() { Response::Results { items } => { // A nonsense query should return empty or very few results // (no items will fuzzy-match "nonexistent_query_xyz") - assert!(items.len() <= 5, "expected few/no results for gibberish query"); + assert!( + items.len() <= 5, + "expected few/no results for gibberish query" + ); } other => panic!("expected Results response, got: {:?}", other), } @@ -172,7 +179,10 @@ fn test_server_handles_submenu_for_unknown_plugin() { "error should mention the plugin id" ); } - other => panic!("expected Error response for unknown plugin, got: {:?}", other), + other => panic!( + "expected Error response for unknown plugin, got: {:?}", + other + ), } drop(stream); diff --git a/crates/owlry-lua/Cargo.toml b/crates/owlry-lua/Cargo.toml index f40be96..b99159a 100644 --- a/crates/owlry-lua/Cargo.toml +++ b/crates/owlry-lua/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "owlry-lua" -version = "0.4.10" +version = "1.0.0" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/owlry-lua/src/api/provider.rs b/crates/owlry-lua/src/api/provider.rs index bf49aa3..12afb76 100644 --- a/crates/owlry-lua/src/api/provider.rs +++ b/crates/owlry-lua/src/api/provider.rs @@ -24,11 +24,14 @@ pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { /// 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")? + let display_name: String = config + .get::>("display_name")? .unwrap_or_else(|| name.clone()); - let type_id: String = config.get::>("type_id")? + let type_id: String = config + .get::>("type_id")? .unwrap_or_else(|| name.replace('-', "_")); - let default_icon: String = config.get::>("default_icon")? + let default_icon: String = config + .get::>("default_icon")? .unwrap_or_else(|| "application-x-addon".to_string()); let prefix: Option = config.get("prefix")?; @@ -116,13 +119,14 @@ fn call_provider_function( // 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); - } + && 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 @@ -153,7 +157,9 @@ fn parse_item(table: &Table) -> LuaResult { 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 tags: Vec = table + .get::>>("tags")? + .unwrap_or_default(); let mut item = PluginItem::new(id, name, command); @@ -176,7 +182,7 @@ fn parse_item(table: &Table) -> LuaResult { #[cfg(test)] mod tests { use super::*; - use crate::runtime::{create_lua_runtime, SandboxConfig}; + use crate::runtime::{SandboxConfig, create_lua_runtime}; #[test] fn test_register_static_provider() { diff --git a/crates/owlry-lua/src/api/utils.rs b/crates/owlry-lua/src/api/utils.rs index 4c9058d..9fcac79 100644 --- a/crates/owlry-lua/src/api/utils.rs +++ b/crates/owlry-lua/src/api/utils.rs @@ -11,25 +11,37 @@ use std::path::{Path, PathBuf}; 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( + "debug", + lua.create_function(|_, msg: String| { + eprintln!("[DEBUG] {}", msg); + Ok(()) + })?, + )?; - log.set("info", lua.create_function(|_, msg: String| { - eprintln!("[INFO] {}", 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( + "warn", + lua.create_function(|_, msg: String| { + eprintln!("[WARN] {}", msg); + Ok(()) + })?, + )?; - log.set("error", lua.create_function(|_, msg: String| { - eprintln!("[ERROR] {}", msg); - Ok(()) - })?)?; + log.set( + "error", + lua.create_function(|_, msg: String| { + eprintln!("[ERROR] {}", msg); + Ok(()) + })?, + )?; owlry.set("log", log)?; Ok(()) @@ -44,59 +56,79 @@ pub fn register_path_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResu 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()) - })?)?; + 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()) - })?)?; + 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()) - })?)?; + 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()) - })?)?; + 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()) - })?)?; + 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()) - })?)?; + 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() { + 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) - })?)?; + Ok(path) + })?, + )?; owlry.set("path", path)?; Ok(()) @@ -111,76 +143,95 @@ pub fn register_fs_api(lua: &Lua, owlry: &Table, _plugin_dir: &Path) -> LuaResul 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()) - })?)?; + 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()) - })?)?; + 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), - } - })?)?; + 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)?)) + 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), } - 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)?)) + 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), } - 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) { + 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), } - 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()) - })?)?; + 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(()) @@ -195,18 +246,24 @@ 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())) - })?)?; + 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), - } - })?)?; + 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(()) @@ -219,9 +276,10 @@ pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { /// 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(); - } + && let Some(home) = dirs::home_dir() + { + return home.join(&path[2..]).to_string_lossy().to_string(); + } path.to_string() } @@ -305,7 +363,7 @@ fn lua_to_json(_lua: &Lua, value: &Value) -> LuaResult { #[cfg(test)] mod tests { use super::*; - use crate::runtime::{create_lua_runtime, SandboxConfig}; + use crate::runtime::{SandboxConfig, create_lua_runtime}; #[test] fn test_log_api() { @@ -316,7 +374,10 @@ mod tests { lua.globals().set("owlry", owlry).unwrap(); // Just verify it doesn't panic - lua.load("owlry.log.info('test message')").set_name("test").call::<()>(()).unwrap(); + lua.load("owlry.log.info('test message')") + .set_name("test") + .call::<()>(()) + .unwrap(); } #[test] @@ -327,10 +388,18 @@ mod tests { 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(); + 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(); + let plugin_dir: String = lua + .load("return owlry.path.plugin_dir()") + .set_name("test") + .call(()) + .unwrap(); assert_eq!(plugin_dir, "/tmp/test-plugin"); } @@ -342,10 +411,18 @@ mod tests { 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(); + 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(); + let is_dir: bool = lua + .load("return owlry.fs.is_dir('/tmp')") + .set_name("test") + .call(()) + .unwrap(); assert!(is_dir); } diff --git a/crates/owlry-lua/src/lib.rs b/crates/owlry-lua/src/lib.rs index 2ff1204..2efc0c3 100644 --- a/crates/owlry-lua/src/lib.rs +++ b/crates/owlry-lua/src/lib.rs @@ -54,7 +54,11 @@ pub struct LuaRuntimeVTable { /// 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, + 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), } @@ -83,11 +87,15 @@ impl RuntimeHandle { /// Create a null handle (reserved for error cases) #[allow(dead_code)] fn null() -> Self { - Self { ptr: std::ptr::null_mut() } + Self { + ptr: std::ptr::null_mut(), + } } fn from_box(state: Box) -> Self { - Self { ptr: Box::into_raw(state) as *mut () } + Self { + ptr: Box::into_raw(state) as *mut (), + } } unsafe fn drop_as(&self) { @@ -147,7 +155,10 @@ impl LuaRuntimeState { 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); + eprintln!( + "owlry-lua: Plugin '{}' not compatible with owlry {}", + id, owlry_version + ); continue; } @@ -285,13 +296,19 @@ extern "C" fn runtime_refresh(handle: RuntimeHandle, provider_id: RStr<'_>) -> R state.refresh_provider(provider_id.as_str()).into() } -extern "C" fn runtime_query(handle: RuntimeHandle, provider_id: RStr<'_>, query: RStr<'_>) -> RVec { +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() + state + .query_provider(provider_id.as_str(), query.as_str()) + .into() } extern "C" fn runtime_drop(handle: RuntimeHandle) { diff --git a/crates/owlry-lua/src/loader.rs b/crates/owlry-lua/src/loader.rs index c5f5fd4..169ffea 100644 --- a/crates/owlry-lua/src/loader.rs +++ b/crates/owlry-lua/src/loader.rs @@ -8,7 +8,7 @@ use owlry_plugin_api::PluginItem; use crate::api; use crate::manifest::PluginManifest; -use crate::runtime::{create_lua_runtime, load_file, SandboxConfig}; +use crate::runtime::{SandboxConfig, create_lua_runtime, load_file}; /// Provider registration info from Lua #[derive(Debug, Clone)] @@ -77,11 +77,13 @@ impl LoadedPlugin { // 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)); + 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))?; + load_file(&lua, &entry_path).map_err(|e| format!("Failed to load entry point: {}", e))?; self.lua = Some(lua); Ok(()) @@ -89,7 +91,9 @@ impl LoadedPlugin { /// Get provider registrations from this plugin pub fn get_provider_registrations(&self) -> Result, String> { - let lua = self.lua.as_ref() + let lua = self + .lua + .as_ref() .ok_or_else(|| "Plugin not initialized".to_string())?; api::get_provider_registrations(lua) @@ -98,25 +102,33 @@ impl LoadedPlugin { /// Call a provider's refresh function pub fn call_provider_refresh(&self, provider_name: &str) -> Result, String> { - let lua = self.lua.as_ref() + let lua = self + .lua + .as_ref() .ok_or_else(|| "Plugin not initialized".to_string())?; - api::call_refresh(lua, provider_name) - .map_err(|e| format!("Refresh failed: {}", e)) + api::call_refresh(lua, provider_name).map_err(|e| format!("Refresh failed: {}", e)) } /// 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() + 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)) + 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> { +pub fn discover_plugins( + plugins_dir: &Path, +) -> Result, String> { let mut plugins = HashMap::new(); if !plugins_dir.exists() { @@ -146,13 +158,21 @@ pub fn discover_plugins(plugins_dir: &Path) -> Result { let id = manifest.plugin.id.clone(); if plugins.contains_key(&id) { - eprintln!("owlry-lua: Duplicate plugin ID '{}', skipping {}", id, path.display()); + eprintln!( + "owlry-lua: Duplicate plugin ID '{}', skipping {}", + id, + path.display() + ); continue; } plugins.insert(id, (manifest, path)); } Err(e) => { - eprintln!("owlry-lua: Failed to load plugin at {}: {}", path.display(), e); + eprintln!( + "owlry-lua: Failed to load plugin at {}: {}", + path.display(), + e + ); } } } diff --git a/crates/owlry-lua/src/manifest.rs b/crates/owlry-lua/src/manifest.rs index fcdd69a..7d0801e 100644 --- a/crates/owlry-lua/src/manifest.rs +++ b/crates/owlry-lua/src/manifest.rs @@ -90,10 +90,10 @@ pub struct PluginPermissions { 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))?; + 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) } @@ -105,7 +105,12 @@ impl PluginManifest { 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 == '-') { + 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()); } @@ -116,7 +121,10 @@ impl PluginManifest { // 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)); + return Err(format!( + "Invalid owlry_version constraint: {}", + self.plugin.owlry_version + )); } Ok(()) diff --git a/crates/owlry-lua/src/runtime.rs b/crates/owlry-lua/src/runtime.rs index 4a2664c..dfea9b7 100644 --- a/crates/owlry-lua/src/runtime.rs +++ b/crates/owlry-lua/src/runtime.rs @@ -28,7 +28,7 @@ impl Default for SandboxConfig { allow_commands: false, allow_network: false, allow_external_fs: false, - max_run_time_ms: 5000, // 5 seconds + max_run_time_ms: 5000, // 5 seconds max_memory: 64 * 1024 * 1024, // 64 MB } } @@ -50,11 +50,7 @@ impl SandboxConfig { 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 libs = StdLib::COROUTINE | StdLib::TABLE | StdLib::STRING | StdLib::UTF8 | StdLib::MATH; let lua = Lua::new_with(libs, mlua::LuaOptions::default())?; @@ -74,11 +70,15 @@ fn setup_safe_globals(lua: &Lua) -> LuaResult<()> { // 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( + "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( + "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)?; @@ -107,8 +107,7 @@ fn os_time(_lua: &Lua, _args: ()) -> LuaResult { /// 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)?; + 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()? diff --git a/crates/owlry-plugin-api/Cargo.toml b/crates/owlry-plugin-api/Cargo.toml index 4fdddc9..c9ad3b1 100644 --- a/crates/owlry-plugin-api/Cargo.toml +++ b/crates/owlry-plugin-api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "owlry-plugin-api" -version = "0.4.10" +version = "1.0.0" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/owlry-plugin-api/src/lib.rs b/crates/owlry-plugin-api/src/lib.rs index 388a7db..01b8930 100644 --- a/crates/owlry-plugin-api/src/lib.rs +++ b/crates/owlry-plugin-api/src/lib.rs @@ -284,12 +284,8 @@ pub enum NotifyUrgency { 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, - ), + 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<'_>), diff --git a/crates/owlry-rune/Cargo.toml b/crates/owlry-rune/Cargo.toml index 713183c..196d295 100644 --- a/crates/owlry-rune/Cargo.toml +++ b/crates/owlry-rune/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "owlry-rune" -version = "0.4.10" +version = "1.0.0" edition = "2024" rust-version = "1.90" description = "Rune scripting runtime for owlry plugins" diff --git a/crates/owlry-rune/src/lib.rs b/crates/owlry-rune/src/lib.rs index 4476840..0a0f877 100644 --- a/crates/owlry-rune/src/lib.rs +++ b/crates/owlry-rune/src/lib.rs @@ -75,7 +75,11 @@ pub struct RuneRuntimeVTable { pub init: extern "C" fn(plugins_dir: 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 query: extern "C" fn( + handle: RuntimeHandle, + provider_id: RStr<'_>, + query: RStr<'_>, + ) -> RVec, pub drop: extern "C" fn(handle: RuntimeHandle), } @@ -94,7 +98,10 @@ extern "C" fn runtime_init(plugins_dir: RStr<'_>) -> RuntimeHandle { let _ = env_logger::try_init(); let plugins_dir = PathBuf::from(plugins_dir.as_str()); - log::info!("Initializing Rune runtime with plugins from: {}", plugins_dir.display()); + log::info!( + "Initializing Rune runtime with plugins from: {}", + plugins_dir.display() + ); let mut state = RuntimeState { plugins: HashMap::new(), @@ -113,15 +120,20 @@ extern "C" fn runtime_init(plugins_dir: RStr<'_>) -> RuntimeHandle { 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() + prefix: reg + .prefix + .as_ref() .map(|p| RString::from(p.as_str())) .into(), }); } state.plugins.insert(id, plugin); } - log::info!("Loaded {} Rune plugin(s) with {} provider(s)", - state.plugins.len(), state.providers.len()); + 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); diff --git a/crates/owlry-rune/src/loader.rs b/crates/owlry-rune/src/loader.rs index 9c0a869..9334385 100644 --- a/crates/owlry-rune/src/loader.rs +++ b/crates/owlry-rune/src/loader.rs @@ -8,7 +8,7 @@ use rune::{Context, Unit}; use crate::api::{self, ProviderRegistration}; use crate::manifest::PluginManifest; -use crate::runtime::{compile_source, create_context, create_vm, SandboxConfig}; +use crate::runtime::{SandboxConfig, compile_source, create_context, create_vm}; use owlry_plugin_api::PluginItem; @@ -29,8 +29,8 @@ 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 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() { @@ -45,15 +45,14 @@ impl LoadedPlugin { .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))?; + 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(()); + let _: () = rune::from_value(result).unwrap_or(()); } Err(_) => { // No main function is okay @@ -111,7 +110,10 @@ pub fn discover_rune_plugins(plugins_dir: &Path) -> Result Result m, Err(e) => { - log::warn!("Failed to load manifest at {}: {}", manifest_path.display(), e); + log::warn!( + "Failed to load manifest at {}: {}", + manifest_path.display(), + e + ); continue; } }; diff --git a/crates/owlry-rune/src/manifest.rs b/crates/owlry-rune/src/manifest.rs index 7c7946b..4dfab81 100644 --- a/crates/owlry-rune/src/manifest.rs +++ b/crates/owlry-rune/src/manifest.rs @@ -64,10 +64,10 @@ pub struct PluginPermissions { 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))?; + 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) } @@ -78,7 +78,12 @@ impl PluginManifest { 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 == '-') { + 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()); } diff --git a/crates/owlry-rune/src/runtime.rs b/crates/owlry-rune/src/runtime.rs index 7b60310..8c7e9f0 100644 --- a/crates/owlry-rune/src/runtime.rs +++ b/crates/owlry-rune/src/runtime.rs @@ -25,7 +25,6 @@ pub struct SandboxConfig { pub allowed_commands: Vec, } - impl SandboxConfig { /// Create sandbox config from plugin permissions pub fn from_permissions(permissions: &PluginPermissions) -> Self { @@ -59,12 +58,9 @@ pub fn create_context(sandbox: &SandboxConfig) -> Result Result, CompileError> { - let source_content = std::fs::read_to_string(source_path) - .map_err(|e| CompileError::Io(e.to_string()))?; +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() @@ -73,7 +69,10 @@ pub fn compile_source( let mut sources = Sources::new(); sources - .insert(Source::new(source_name, &source_content).map_err(|e| CompileError::Compile(e.to_string()))?) + .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(); @@ -97,13 +96,11 @@ pub fn compile_source( } /// Create a new Rune VM from compiled unit -pub fn create_vm( - context: &Context, - unit: Arc, -) -> Result { +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)))? + context + .runtime() + .map_err(|e| CompileError::Compile(format!("Failed to get runtime: {}", e)))?, ); Ok(Vm::new(runtime, unit)) } diff --git a/crates/owlry/Cargo.toml b/crates/owlry/Cargo.toml index 35b89ca..86e5b65 100644 --- a/crates/owlry/Cargo.toml +++ b/crates/owlry/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "owlry" -version = "0.4.10" +version = "1.0.0" edition = "2024" rust-version = "1.90" description = "A lightweight, owl-themed application launcher for Wayland" diff --git a/crates/owlry/src/app.rs b/crates/owlry/src/app.rs index 918140b..9244ade 100644 --- a/crates/owlry/src/app.rs +++ b/crates/owlry/src/app.rs @@ -4,15 +4,15 @@ use crate::client::CoreClient; use crate::providers::DmenuProvider; 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 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 gtk4::prelude::*; -use gtk4::{gio, Application, CssProvider}; -use gtk4_layer_shell::{Edge, Layer, LayerShell}; -use log::{debug, info, warn}; use std::cell::RefCell; use std::rc::Rc; @@ -61,7 +61,7 @@ impl OwlryApp { let frecency = FrecencyStore::load_or_default(); SearchBackend::Local { - providers: provider_manager, + providers: Box::new(provider_manager), frecency, } } else { @@ -98,11 +98,7 @@ impl OwlryApp { &config.borrow().providers, ) } else { - ProviderFilter::new( - None, - Some(provider_types), - &config.borrow().providers, - ) + ProviderFilter::new(None, Some(provider_types), &config.borrow().providers) } } else { ProviderFilter::new(None, None, &config.borrow().providers) @@ -180,7 +176,7 @@ impl OwlryApp { let frecency = FrecencyStore::load_or_default(); SearchBackend::Local { - providers: provider_manager, + providers: Box::new(provider_manager), frecency, } } @@ -241,16 +237,17 @@ impl OwlryApp { // 3. Load user's custom stylesheet if exists if let Some(custom_path) = paths::custom_style_file() - && custom_path.exists() { - let custom_provider = CssProvider::new(); - custom_provider.load_from_path(&custom_path); - gtk4::style_context_add_provider_for_display( - &display, - &custom_provider, - gtk4::STYLE_PROVIDER_PRIORITY_USER, - ); - debug!("Loaded custom CSS from {:?}", custom_path); - } + && custom_path.exists() + { + let custom_provider = CssProvider::new(); + custom_provider.load_from_path(&custom_path); + gtk4::style_context_add_provider_for_display( + &display, + &custom_provider, + gtk4::STYLE_PROVIDER_PRIORITY_USER, + ); + debug!("Loaded custom CSS from {:?}", custom_path); + } // 4. Inject config variables (highest priority for overrides) let vars_css = theme::generate_variables_css(&config.appearance); diff --git a/crates/owlry/src/backend.rs b/crates/owlry/src/backend.rs index 62faf65..254f163 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 owlry_core::config::Config; +use owlry_core::data::FrecencyStore; use owlry_core::filter::ProviderFilter; use owlry_core::ipc::ResultItem; use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType}; -use owlry_core::data::FrecencyStore; -use owlry_core::config::Config; -use log::warn; /// Backend for search operations. Wraps either an IPC client (daemon mode) /// or a local ProviderManager (dmenu mode). @@ -18,7 +18,7 @@ pub enum SearchBackend { Daemon(CoreClient), /// Direct local provider manager (dmenu mode only) Local { - providers: ProviderManager, + providers: Box, frecency: FrecencyStore, }, } @@ -64,7 +64,14 @@ impl SearchBackend { if use_frecency { providers - .search_with_frecency(query, max_results, filter, frecency, frecency_weight, None) + .search_with_frecency( + query, + max_results, + filter, + frecency, + frecency_weight, + None, + ) .into_iter() .map(|(item, _)| item) .collect() @@ -123,7 +130,14 @@ impl SearchBackend { if use_frecency { providers - .search_with_frecency(query, max_results, filter, frecency, frecency_weight, tag_filter) + .search_with_frecency( + query, + max_results, + filter, + frecency, + frecency_weight, + tag_filter, + ) .into_iter() .map(|(item, _)| item) .collect() @@ -141,18 +155,14 @@ impl SearchBackend { /// Execute a plugin action command. Returns true if handled. pub fn execute_plugin_action(&mut self, command: &str) -> bool { match self { - SearchBackend::Daemon(client) => { - match client.plugin_action(command) { - Ok(handled) => handled, - Err(e) => { - warn!("IPC plugin_action failed: {}", e); - false - } + SearchBackend::Daemon(client) => match client.plugin_action(command) { + Ok(handled) => handled, + Err(e) => { + warn!("IPC plugin_action failed: {}", e); + false } - } - SearchBackend::Local { providers, .. } => { - providers.execute_plugin_action(command) - } + }, + SearchBackend::Local { providers, .. } => providers.execute_plugin_action(command), } } @@ -165,20 +175,18 @@ impl SearchBackend { display_name: &str, ) -> Option<(String, Vec)> { match self { - SearchBackend::Daemon(client) => { - match client.submenu(plugin_id, data) { - Ok(items) if !items.is_empty() => { - let actions: Vec = - items.into_iter().map(result_to_launch_item).collect(); - Some((display_name.to_string(), actions)) - } - Ok(_) => None, - Err(e) => { - warn!("IPC submenu query failed: {}", e); - None - } + SearchBackend::Daemon(client) => match client.submenu(plugin_id, data) { + Ok(items) if !items.is_empty() => { + let actions: Vec = + items.into_iter().map(result_to_launch_item).collect(); + Some((display_name.to_string(), actions)) } - } + Ok(_) => None, + Err(e) => { + warn!("IPC submenu query failed: {}", e); + None + } + }, SearchBackend::Local { providers, .. } => { providers.query_submenu_actions(plugin_id, data, display_name) } @@ -218,22 +226,18 @@ impl SearchBackend { #[allow(dead_code)] pub fn available_provider_ids(&mut self) -> Vec { match self { - SearchBackend::Daemon(client) => { - match client.providers() { - Ok(descs) => descs.into_iter().map(|d| d.id).collect(), - Err(e) => { - warn!("IPC providers query failed: {}", e); - Vec::new() - } + SearchBackend::Daemon(client) => match client.providers() { + Ok(descs) => descs.into_iter().map(|d| d.id).collect(), + Err(e) => { + warn!("IPC providers query failed: {}", e); + Vec::new() } - } - SearchBackend::Local { providers, .. } => { - providers - .available_providers() - .into_iter() - .map(|d| d.id) - .collect() - } + }, + SearchBackend::Local { providers, .. } => providers + .available_providers() + .into_iter() + .map(|d| d.id) + .collect(), } } } diff --git a/crates/owlry/src/client.rs b/crates/owlry/src/client.rs index 836c60a..0f05213 100644 --- a/crates/owlry/src/client.rs +++ b/crates/owlry/src/client.rs @@ -41,20 +41,14 @@ impl CoreClient { .args(["--user", "start", "owlry-core"]) .status() .map_err(|e| { - io::Error::new( - io::ErrorKind::Other, - format!("failed to start owlry-core via systemd: {e}"), - ) + io::Error::other(format!("failed to start owlry-core via systemd: {e}")) })?; if !status.success() { - return Err(io::Error::new( - io::ErrorKind::Other, - format!( - "systemctl --user start owlry-core exited with status {}", - status - ), - )); + return Err(io::Error::other(format!( + "systemctl --user start owlry-core exited with status {}", + status + ))); } // Retry with exponential backoff. @@ -66,9 +60,7 @@ impl CoreClient { Err(e) if i == delays.len() - 1 => { return Err(io::Error::new( io::ErrorKind::ConnectionRefused, - format!( - "daemon started but socket not available after retries: {e}" - ), + format!("daemon started but socket not available after retries: {e}"), )); } Err(_) => continue, @@ -87,11 +79,7 @@ impl CoreClient { } /// Send a search query and return matching results. - pub fn query( - &mut self, - text: &str, - modes: Option>, - ) -> io::Result> { + pub fn query(&mut self, text: &str, modes: Option>) -> io::Result> { self.send(&Request::Query { text: text.to_string(), modes, @@ -99,9 +87,7 @@ impl CoreClient { match self.receive()? { Response::Results { items } => Ok(items), - Response::Error { message } => { - Err(io::Error::new(io::ErrorKind::Other, message)) - } + Response::Error { message } => Err(io::Error::other(message)), other => Err(io::Error::new( io::ErrorKind::InvalidData, format!("unexpected response to Query: {other:?}"), @@ -118,9 +104,7 @@ impl CoreClient { match self.receive()? { Response::Ack => Ok(()), - Response::Error { message } => { - Err(io::Error::new(io::ErrorKind::Other, message)) - } + Response::Error { message } => Err(io::Error::other(message)), other => Err(io::Error::new( io::ErrorKind::InvalidData, format!("unexpected response to Launch: {other:?}"), @@ -134,9 +118,7 @@ impl CoreClient { match self.receive()? { Response::Providers { list } => Ok(list), - Response::Error { message } => { - Err(io::Error::new(io::ErrorKind::Other, message)) - } + Response::Error { message } => Err(io::Error::other(message)), other => Err(io::Error::new( io::ErrorKind::InvalidData, format!("unexpected response to Providers: {other:?}"), @@ -150,9 +132,7 @@ impl CoreClient { match self.receive()? { Response::Ack => Ok(()), - Response::Error { message } => { - Err(io::Error::new(io::ErrorKind::Other, message)) - } + Response::Error { message } => Err(io::Error::other(message)), other => Err(io::Error::new( io::ErrorKind::InvalidData, format!("unexpected response to Toggle: {other:?}"), @@ -178,11 +158,7 @@ impl CoreClient { } /// Query a plugin's submenu actions. - pub fn submenu( - &mut self, - plugin_id: &str, - data: &str, - ) -> io::Result> { + pub fn submenu(&mut self, plugin_id: &str, data: &str) -> io::Result> { self.send(&Request::Submenu { plugin_id: plugin_id.to_string(), data: data.to_string(), @@ -190,9 +166,7 @@ impl CoreClient { match self.receive()? { Response::SubmenuItems { items } => Ok(items), - Response::Error { message } => { - Err(io::Error::new(io::ErrorKind::Other, message)) - } + Response::Error { message } => Err(io::Error::other(message)), other => Err(io::Error::new( io::ErrorKind::InvalidData, format!("unexpected response to Submenu: {other:?}"), @@ -220,8 +194,7 @@ impl CoreClient { "daemon closed the connection", )); } - serde_json::from_str(line.trim()) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) + serde_json::from_str(line.trim()).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) } } @@ -239,11 +212,7 @@ mod tests { /// socket path to avoid collisions when tests run in parallel. fn mock_server(response: Response) -> PathBuf { let n = COUNTER.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "owlry-test-{}-{}", - std::process::id(), - n - )); + let dir = std::env::temp_dir().join(format!("owlry-test-{}-{}", std::process::id(), n)); let _ = std::fs::create_dir_all(&dir); let sock = dir.join("test.sock"); let _ = std::fs::remove_file(&sock); diff --git a/crates/owlry/src/main.rs b/crates/owlry/src/main.rs index 90a37aa..1e54301 100644 --- a/crates/owlry/src/main.rs +++ b/crates/owlry/src/main.rs @@ -1,7 +1,7 @@ mod app; mod backend; -pub mod client; mod cli; +pub mod client; mod plugin_commands; mod providers; mod theme; @@ -65,7 +65,11 @@ fn main() { } // No subcommand - launch the app - 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() diff --git a/crates/owlry/src/plugin_commands.rs b/crates/owlry/src/plugin_commands.rs index 731f47c..7780002 100644 --- a/crates/owlry/src/plugin_commands.rs +++ b/crates/owlry/src/plugin_commands.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use crate::cli::{PluginCommand as CliPluginCommand, PluginRuntime}; use owlry_core::config::Config; use owlry_core::paths; -use owlry_core::plugins::manifest::{discover_plugins, PluginManifest}; +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}; @@ -46,15 +46,30 @@ fn any_runtime_available() -> bool { /// Execute a plugin command pub fn execute(cmd: CliPluginCommand) -> CommandResult { match cmd { - CliPluginCommand::List { enabled, disabled, runtime, available, refresh, json } => { + 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 } => { + CliPluginCommand::Search { + query, + refresh, + json, + } => cmd_search(&query, refresh, json), + CliPluginCommand::Info { + name, + registry, + json, + } => { if registry { cmd_info_registry(&name, json) } else { @@ -74,15 +89,29 @@ pub fn execute(cmd: CliPluginCommand) -> CommandResult { 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 } => { + 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()) + 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::Run { + plugin_id, + command, + args, + } => cmd_run_plugin_command(&plugin_id, &command, &args), CliPluginCommand::Commands { plugin_id } => cmd_list_commands(plugin_id.as_deref()), } } @@ -351,7 +380,10 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult { }); println!("{}", serde_json::to_string_pretty(&info).unwrap()); } else { - println!("Plugin: {} v{}", manifest.plugin.name, manifest.plugin.version); + 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); @@ -359,11 +391,18 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult { if !manifest.plugin.author.is_empty() { println!("Author: {}", manifest.plugin.author); } - println!("Status: {}", if is_enabled { "enabled" } else { "disabled" }); + println!( + "Status: {}", + if is_enabled { "enabled" } else { "disabled" } + ); println!( "Runtime: {}{}", runtime, - if runtime_available { "" } else { " (NOT INSTALLED)" } + if runtime_available { + "" + } else { + " (NOT INSTALLED)" + } ); println!("Path: {}", plugin_path.display()); println!(); @@ -382,12 +421,25 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult { } println!(); println!("Permissions:"); - println!(" Network: {}", if manifest.permissions.network { "yes" } else { "no" }); + println!( + " Network: {}", + if manifest.permissions.network { + "yes" + } else { + "no" + } + ); if !manifest.permissions.filesystem.is_empty() { - println!(" Filesystem: {}", manifest.permissions.filesystem.join(", ")); + println!( + " Filesystem: {}", + manifest.permissions.filesystem.join(", ") + ); } if !manifest.permissions.run_commands.is_empty() { - println!(" Commands: {}", manifest.permissions.run_commands.join(", ")); + println!( + " Commands: {}", + manifest.permissions.run_commands.join(", ") + ); } } @@ -398,7 +450,8 @@ fn cmd_info_installed(name: &str, json_output: bool) -> CommandResult { fn cmd_info_registry(name: &str, json_output: bool) -> CommandResult { let client = get_registry_client(); - let plugin = client.find(name, false)? + let plugin = client + .find(name, false)? .ok_or_else(|| format!("Plugin '{}' not found in registry", name))?; if json_output { @@ -466,12 +519,10 @@ fn cmd_install(source: &str, force: bool) -> CommandResult { 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 - )) - } + None => Err(format!( + "Plugin '{}' not found in registry. Use a local path or git URL.", + source + )), } } } @@ -597,8 +648,7 @@ fn cmd_remove(name: &str, yes: bool) -> CommandResult { } } - fs::remove_dir_all(&plugin_path) - .map_err(|e| format!("Failed to remove plugin: {}", e))?; + 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() { @@ -645,7 +695,9 @@ fn cmd_enable(name: &str) -> CommandResult { } config.plugins.disabled_plugins.retain(|id| id != name); - config.save().map_err(|e| format!("Failed to save config: {}", e))?; + config + .save() + .map_err(|e| format!("Failed to save config: {}", e))?; println!("Enabled plugin '{}'", name); Ok(()) @@ -668,7 +720,9 @@ fn cmd_disable(name: &str) -> CommandResult { } config.plugins.disabled_plugins.push(name.to_string()); - config.save().map_err(|e| format!("Failed to save config: {}", e))?; + config + .save() + .map_err(|e| format!("Failed to save config: {}", e))?; println!("Disabled plugin '{}'", name); Ok(()) @@ -688,11 +742,13 @@ fn cmd_create( let plugin_dir = base_dir.join(name); if plugin_dir.exists() { - return Err(format!("Directory '{}' already exists", plugin_dir.display())); + return Err(format!( + "Directory '{}' already exists", + plugin_dir.display() + )); } - fs::create_dir_all(&plugin_dir) - .map_err(|e| format!("Failed to create directory: {}", e))?; + 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"); @@ -825,14 +881,28 @@ pub fn register(owlry) {{{{ } } - println!("Created {} plugin '{}' at {}", runtime, name, plugin_dir.display()); + 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!( + " 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); + println!( + "Runtime: {} (requires owlry-{} package)", + runtime, entry_ext + ); Ok(()) } @@ -996,15 +1066,29 @@ fn cmd_run_plugin_command(plugin_id: &str, command: &str, args: &[String]) -> Co .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); + 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(); + 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 provide any CLI commands", + plugin_id + )); } return Err(format!( "Plugin '{}' does not have command '{}'. Available: {}", - plugin_id, command, available.join(", ") + plugin_id, + command, + available.join(", ") )); } @@ -1030,10 +1114,8 @@ fn execute_plugin_command( // Load the appropriate runtime let loaded_runtime = match runtime { - PluginRuntime::Lua => { - LoadedRuntime::load_lua(plugin_path.parent().unwrap_or(plugin_path)) - .map_err(|e| format!("Failed to load Lua runtime: {}", e))? - } + PluginRuntime::Lua => LoadedRuntime::load_lua(plugin_path.parent().unwrap_or(plugin_path)) + .map_err(|e| format!("Failed to load Lua runtime: {}", e))?, PluginRuntime::Rune => { LoadedRuntime::load_rune(plugin_path.parent().unwrap_or(plugin_path)) .map_err(|e| format!("Failed to load Rune runtime: {}", e))? @@ -1047,7 +1129,10 @@ fn execute_plugin_command( let _query = query_parts.join(":"); // Find the provider from this plugin and send the command query - let _provider_name = manifest.provides.providers.first() + let _provider_name = manifest + .provides + .providers + .first() .ok_or_else(|| format!("Plugin '{}' has no providers", manifest.plugin.id))?; // Query the provider with the command @@ -1056,14 +1141,31 @@ fn execute_plugin_command( // 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!( + "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" }); + 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: @@ -1087,7 +1189,8 @@ fn cmd_list_commands(plugin_id: Option<&str>) -> CommandResult { if let Some(id) = plugin_id { // Show commands for a specific plugin - let (manifest, _path) = discovered.get(id) + let (manifest, _path) = discovered + .get(id) .ok_or_else(|| format!("Plugin '{}' not found", id))?; if manifest.provides.commands.is_empty() { diff --git a/crates/owlry/src/providers/dmenu.rs b/crates/owlry/src/providers/dmenu.rs index 1a6b8b3..99a9391 100644 --- a/crates/owlry/src/providers/dmenu.rs +++ b/crates/owlry/src/providers/dmenu.rs @@ -1,5 +1,5 @@ -use owlry_core::providers::{LaunchItem, Provider, ProviderType}; use log::debug; +use owlry_core::providers::{LaunchItem, Provider, ProviderType}; use std::io::{self, BufRead}; /// Provider for dmenu-style input from stdin diff --git a/crates/owlry/src/theme.rs b/crates/owlry/src/theme.rs index fd3b01b..f0b11e8 100644 --- a/crates/owlry/src/theme.rs +++ b/crates/owlry/src/theme.rs @@ -6,7 +6,10 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String { // Always inject layout config values css.push_str(&format!(" --owlry-font-size: {}px;\n", config.font_size)); - css.push_str(&format!(" --owlry-border-radius: {}px;\n", config.border_radius)); + css.push_str(&format!( + " --owlry-border-radius: {}px;\n", + config.border_radius + )); // Only inject colors if user specified them if let Some(ref bg) = config.colors.background { @@ -22,7 +25,10 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String { css.push_str(&format!(" --owlry-text: {};\n", text)); } if let Some(ref text_secondary) = config.colors.text_secondary { - css.push_str(&format!(" --owlry-text-secondary: {};\n", text_secondary)); + css.push_str(&format!( + " --owlry-text-secondary: {};\n", + text_secondary + )); } if let Some(ref accent) = config.colors.accent { css.push_str(&format!(" --owlry-accent: {};\n", accent)); @@ -36,7 +42,10 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String { css.push_str(&format!(" --owlry-badge-app: {};\n", badge_app)); } if let Some(ref badge_bookmark) = config.colors.badge_bookmark { - css.push_str(&format!(" --owlry-badge-bookmark: {};\n", badge_bookmark)); + css.push_str(&format!( + " --owlry-badge-bookmark: {};\n", + badge_bookmark + )); } if let Some(ref badge_calc) = config.colors.badge_calc { css.push_str(&format!(" --owlry-badge-calc: {};\n", badge_calc)); diff --git a/crates/owlry/src/ui/main_window.rs b/crates/owlry/src/ui/main_window.rs index fb4c714..85126b4 100644 --- a/crates/owlry/src/ui/main_window.rs +++ b/crates/owlry/src/ui/main_window.rs @@ -1,9 +1,6 @@ use crate::backend::SearchBackend; -use owlry_core::config::Config; -use owlry_core::filter::ProviderFilter; -use owlry_core::providers::{LaunchItem, ProviderType}; -use crate::ui::submenu; use crate::ui::ResultRow; +use crate::ui::submenu; use gtk4::gdk::Key; use gtk4::prelude::*; use gtk4::{ @@ -11,6 +8,9 @@ use gtk4::{ ListBoxRow, Orientation, ScrolledWindow, SelectionMode, ToggleButton, }; use log::info; +use owlry_core::config::Config; +use owlry_core::filter::ProviderFilter; +use owlry_core::providers::{LaunchItem, ProviderType}; #[cfg(feature = "dev-logging")] use log::debug; @@ -148,7 +148,9 @@ impl MainWindow { header_box.append(&filter_tabs); // Search entry with dynamic placeholder (or custom prompt if provided) - let placeholder = custom_prompt.clone().unwrap_or_else(|| Self::build_placeholder(&filter.borrow())); + let placeholder = custom_prompt + .clone() + .unwrap_or_else(|| Self::build_placeholder(&filter.borrow())); let search_entry = Entry::builder() .placeholder_text(&placeholder) .hexpand(true) @@ -293,8 +295,16 @@ impl MainWindow { // Show number hint in the label for first 9 tabs (using superscript) let label = if idx < 9 { let superscript = match idx + 1 { - 1 => "¹", 2 => "²", 3 => "³", 4 => "⁴", 5 => "⁵", - 6 => "⁶", 7 => "⁷", 8 => "⁸", 9 => "⁹", _ => "", + 1 => "¹", + 2 => "²", + 3 => "³", + 4 => "⁴", + 5 => "⁵", + 6 => "⁶", + 7 => "⁷", + 8 => "⁸", + 9 => "⁹", + _ => "", }; format!("{}{}", base_label, superscript) } else { @@ -494,7 +504,11 @@ impl MainWindow { actions: Vec, ) { #[cfg(feature = "dev-logging")] - debug!("[UI] Entering submenu: {} ({} actions)", display_name, actions.len()); + debug!( + "[UI] Entering submenu: {} ({} actions)", + display_name, + actions.len() + ); // Save current state { @@ -705,7 +719,8 @@ impl MainWindow { } // current_results holds only what's displayed (for selection/activation) - *current_results.borrow_mut() = results.into_iter().take(initial_count).collect(); + *current_results.borrow_mut() = + results.into_iter().take(initial_count).collect(); }, ); @@ -736,15 +751,19 @@ impl MainWindow { if let Some(item) = results.get(index) { // Check if this is a submenu item and query the plugin for actions let submenu_result = if submenu::is_submenu_item(item) { - if let Some((plugin_id, data)) = submenu::parse_submenu_command(&item.command) { + if let Some((plugin_id, data)) = + submenu::parse_submenu_command(&item.command) + { // Clone values before dropping borrow let plugin_id = plugin_id.to_string(); let data = data.to_string(); let display_name = item.name.clone(); drop(results); // Release borrow before querying - backend_for_activate - .borrow_mut() - .query_submenu_actions(&plugin_id, &data, &display_name) + backend_for_activate.borrow_mut().query_submenu_actions( + &plugin_id, + &data, + &display_name, + ) } else { drop(results); None @@ -843,7 +862,10 @@ impl MainWindow { let shift = modifiers.contains(gtk4::gdk::ModifierType::SHIFT_MASK); #[cfg(feature = "dev-logging")] - debug!("[UI] Key pressed: {:?} (ctrl={}, shift={})", key, ctrl, shift); + debug!( + "[UI] Key pressed: {:?} (ctrl={}, shift={})", + key, ctrl, shift + ); match key { Key::Escape => { @@ -906,10 +928,11 @@ impl MainWindow { if let Some(selected) = results_list.selected_row() { let prev_index = selected.index() - 1; if prev_index >= 0 - && 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); - } + && 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); + } } gtk4::glib::Propagation::Stop } @@ -941,8 +964,17 @@ impl MainWindow { gtk4::glib::Propagation::Stop } // Ctrl+1-9 toggle specific providers based on tab order (only when not in submenu) - Key::_1 | Key::_2 | Key::_3 | Key::_4 | Key::_5 | - Key::_6 | Key::_7 | Key::_8 | Key::_9 if ctrl => { + Key::_1 + | Key::_2 + | Key::_3 + | Key::_4 + | Key::_5 + | Key::_6 + | Key::_7 + | Key::_8 + | Key::_9 + if ctrl => + { info!("[UI] Ctrl+number detected: {:?}", key); if !submenu_state.borrow().active { let idx = match key { @@ -968,7 +1000,11 @@ impl MainWindow { &mode_label, ); } else { - info!("[UI] No provider at index {}, tab_order len={}", idx, tab_order.len()); + info!( + "[UI] No provider at index {}, tab_order len={}", + idx, + tab_order.len() + ); } } gtk4::glib::Propagation::Stop @@ -1029,7 +1065,8 @@ impl MainWindow { let results = current_results.borrow(); if let Some(item) = results.get(index).cloned() { drop(results); - let should_close = Self::handle_item_action(&item, &config.borrow(), &backend); + let should_close = + Self::handle_item_action(&item, &config.borrow(), &backend); if should_close { window.close(); } else { @@ -1076,7 +1113,11 @@ impl MainWindow { } } else if current.len() == 1 { let idx = tab_order.iter().position(|p| p == ¤t[0]).unwrap_or(0); - let at_boundary = if forward { idx == tab_order.len() - 1 } else { idx == 0 }; + let at_boundary = if forward { + idx == tab_order.len() - 1 + } else { + idx == 0 + }; if at_boundary { // At boundary, go back to "All" mode @@ -1284,11 +1325,14 @@ impl MainWindow { info!("Launching: {} ({})", item.name, item.command); #[cfg(feature = "dev-logging")] - debug!("[UI] Launch details: terminal={}, provider={:?}, id={}", item.terminal, item.provider, item.id); + debug!( + "[UI] Launch details: terminal={}, provider={:?}, id={}", + item.terminal, item.provider, item.id + ); // Check if this is a desktop application (has .desktop file as ID) - let is_desktop_app = matches!(item.provider, ProviderType::Application) - && item.id.ends_with(".desktop"); + let is_desktop_app = + matches!(item.provider, ProviderType::Application) && item.id.ends_with(".desktop"); // Desktop files should be launched via proper launchers that implement the // freedesktop Desktop Entry spec (D-Bus activation, field codes, env vars, etc.) @@ -1315,7 +1359,10 @@ impl MainWindow { /// /// Otherwise, uses `gio launch` which is always available (part of glib2/GTK4) /// and handles D-Bus activation, field codes, Terminal flag, etc. - fn launch_desktop_file(desktop_path: &str, config: &Config) -> std::io::Result { + fn launch_desktop_file( + desktop_path: &str, + config: &Config, + ) -> std::io::Result { use std::path::Path; // Check if desktop file exists @@ -1349,16 +1396,22 @@ impl MainWindow { .spawn() } else { info!("Launching via gio: {}", desktop_path); - Command::new("gio") - .args(["launch", desktop_path]) - .spawn() + Command::new("gio").args(["launch", desktop_path]).spawn() } } /// Launch a shell command (for non-desktop items like PATH commands, plugins, etc.) - fn launch_command(command: &str, terminal: bool, config: &Config) -> std::io::Result { + fn launch_command( + command: &str, + terminal: bool, + config: &Config, + ) -> std::io::Result { let cmd = if terminal { - let terminal_cmd = config.general.terminal_command.as_deref().unwrap_or("xterm"); + let terminal_cmd = config + .general + .terminal_command + .as_deref() + .unwrap_or("xterm"); format!("{} -e {}", terminal_cmd, command) } else { command.to_string() diff --git a/crates/owlry/src/ui/result_row.rs b/crates/owlry/src/ui/result_row.rs index 175bba8..f4366b9 100644 --- a/crates/owlry/src/ui/result_row.rs +++ b/crates/owlry/src/ui/result_row.rs @@ -1,6 +1,6 @@ -use owlry_core::providers::LaunchItem; use gtk4::prelude::*; use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget}; +use owlry_core::providers::LaunchItem; #[allow(dead_code)] pub struct ResultRow { @@ -81,7 +81,9 @@ 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 => "application-x-executable-symbolic", + owlry_core::providers::ProviderType::Application => { + "application-x-executable-symbolic" + } owlry_core::providers::ProviderType::Command => "utilities-terminal-symbolic", owlry_core::providers::ProviderType::Dmenu => "view-list-symbolic", // Plugins should provide their own icon; fallback to generic addon icon @@ -134,9 +136,7 @@ impl ResultRow { .build(); for tag in item.tags.iter().take(3) { - let tag_label = Label::builder() - .label(tag) - .build(); + let tag_label = Label::builder().label(tag).build(); tag_label.add_css_class("owlry-tag-badge"); tags_box.append(&tag_label); }