Files
owlry/docs/superpowers/plans/2026-03-26-architecture-split.md

2364 lines
67 KiB
Markdown

# 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<Vec<String>>,
},
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<ResultItem>,
},
Providers {
list: Vec<ProviderDesc>,
},
SubmenuItems {
items: Vec<ResultItem>,
},
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<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
#[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<String>,
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<Mutex<ProviderManager>>,
frecency: Arc<Mutex<FrecencyStore>>,
config: Arc<Config>,
}
impl Server {
pub fn new(socket_path: &Path) -> std::io::Result<Self> {
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<Mutex<ProviderManager>>,
frecency: &Arc<Mutex<FrecencyStore>>,
config: &Arc<Config>,
) -> 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<Mutex<ProviderManager>>,
frecency: &Arc<Mutex<FrecencyStore>>,
config: &Arc<Config>,
) -> 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::<Request>(&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<ProviderType>, 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<ProviderType> {
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<NativeProvider> {
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<ProviderDescriptor> {
// 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<String>,
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<UnixStream>,
}
impl CoreClient {
pub fn connect(socket_path: &Path) -> std::io::Result<Self> {
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<Self> {
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<Vec<String>>) -> std::io::Result<Vec<ResultItem>> {
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<Vec<ProviderDesc>> {
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<Vec<ResultItem>> {
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<Response> {
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<RefCell<CoreClient>>` 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<fs::File> {
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<String>,
}
// Add to Config struct:
// pub profiles: HashMap<String, ProfileConfig>,
```
- [ ] **Step 2: Update CLI args**
In `crates/owlry/src/cli.rs`:
- Remove `--providers` / `-p` flag
- Add `--profile` flag: `Option<String>`
- Keep `--mode` / `-m` flag
- Repurpose `-p` as short for `--prompt` (dmenu prompt text)
```rust
#[derive(Debug)]
pub struct CliArgs {
pub mode: Option<Vec<String>>,
pub profile: Option<String>,
pub prompt: Option<String>, // was --prompt, now also -p
pub command: Option<Command>,
}
```
- [ ] **Step 3: Resolve profile to modes**
In `app.rs` or `main.rs`, resolve the profile:
```rust
fn resolve_modes(args: &CliArgs, config: &Config) -> Option<Vec<String>> {
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.