67 KiB
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-exportssrc/main.rs— daemon entry pointsrc/server.rs— Unix socket IPC serversrc/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 rootjustfile— plugin-specific build/release automationREADME.md
In systemd/:
owlry-core.service— systemd user serviceowlry-core.socket— socket activation unit
Files to move
From crates/owlry/src/ → crates/owlry-core/src/:
config/mod.rsdata/mod.rs,data/frecency.rsfilter.rsproviders/mod.rs,providers/application.rs,providers/command.rs,providers/native_provider.rs,providers/lua_provider.rsplugins/(entire directory — mod.rs, native_loader.rs, runtime_loader.rs, loader.rs, manifest.rs, registry.rs, commands.rs, error.rs, runtime.rs, api/)notify.rspaths.rs
From crates/owlry-plugin-* → owlry-plugins/crates/owlry-plugin-*:
- All 14 plugin crates
owlry-lua,owlry-runeruntime crates
From aur/ → owlry-plugins/aur/:
- All
owlry-plugin-*directories owlry-lua,owlry-runedirectories
Files to modify significantly
crates/owlry/Cargo.toml— remove backend deps, add owlry-core depcrates/owlry/src/main.rs— remove plugin subcommand handling (moves to owlry-core)crates/owlry/src/app.rs— replace direct provider/plugin calls with IPC clientcrates/owlry/src/cli.rs— remove--providers, add--profile, restructurecrates/owlry/src/ui/main_window.rs— use IPC client instead of direct ProviderManagerCargo.toml(root) — remove plugin/runtime workspace membersjustfile— split into core justfile + plugins justfileREADME.md— update for new architectureCLAUDE.md— update for new structure- All plugin
Cargo.tomlfiles — 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.xabi_stable: check latestreqwest: check latestmlua: check latestrune: check latestclap: check latest 4.xserde: check latest 1.xchrono: 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
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
[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
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-coreto 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
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
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-corecompiles 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::Configstays 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)
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-coredependency toowlry
In crates/owlry/Cargo.toml, add:
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
usestatements in owlry source files
Replace local module imports with owlry-core imports throughout crates/owlry/src/:
// 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:
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
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
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:
# 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:
# Build UI binary only
build-ui:
cargo build -p owlry
Update install-local to install both binaries:
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
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— addpub mod ipc; -
Step 1: Write tests for IPC message serialization
Create crates/owlry-core/tests/ipc_test.rs:
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:
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
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— addpub 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:
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:
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
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— addfrom_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:
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:
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()andrefresh_provider()methods
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
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:
[[bin]]
name = "owlry-core"
path = "src/main.rs"
- Step 2: Implement daemon entry point
Create crates/owlry-core/src/main.rs:
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:
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
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:
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:
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
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
MainWindowto acceptCoreClient
Replace ProviderManager and FrecencyStore fields with CoreClient:
- Search handler: calls
client.query(text, modes)instead ofpm.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
DmenuProviderdirectly 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
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:
- Try connecting to daemon
- If another UI instance is already connected, send
Toggle→ daemon tells the other instance to close - 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
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
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— addProfileConfig -
Step 1: Add profile config structure
In crates/owlry-core/src/config/mod.rs, add:
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/-pflag - Add
--profileflag:Option<String> - Keep
--mode/-mflag - Repurpose
-pas short for--prompt(dmenu prompt text)
#[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:
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
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:
[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:
[Unit]
Description=Owlry launcher socket
[Socket]
ListenStream=%t/owlry/owlry.sock
DirectoryMode=0700
[Install]
WantedBy=sockets.target
- Step 3: Commit
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
--providersreferences -
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
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
mkdir -p /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins
- Step 2: Initialize git repo
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:
[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:
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
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
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:
# 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:
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:
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
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins
cargo build --workspace
Fix any compilation issues from the dependency change.
- Step 5: Commit
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
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:
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
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
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:
[workspace]
members = [
"crates/owlry",
"crates/owlry-core",
"crates/owlry-plugin-api",
]
- Step 2: Delete moved crates
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
rm -rf aur/owlry-plugin-*
rm -rf aur/owlry-lua
rm -rf aur/owlry-rune
- Step 4: Delete moved docs
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:
# 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
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry
cargo build --workspace
cargo test --workspace
- Step 9: Commit
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
# 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
# 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
# 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
# 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
# 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
# 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
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
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:
# 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
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
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
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":
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
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.