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)