# 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.