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

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-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
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-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
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.rscrates/owlry-core/src/filter.rs

  • Move: crates/owlry/src/providers/mod.rscrates/owlry-core/src/providers/mod.rs

  • Move: crates/owlry/src/providers/application.rscrates/owlry-core/src/providers/application.rs

  • Move: crates/owlry/src/providers/command.rscrates/owlry-core/src/providers/command.rs

  • Move: crates/owlry/src/providers/native_provider.rscrates/owlry-core/src/providers/native_provider.rs

  • Move: crates/owlry/src/providers/lua_provider.rscrates/owlry-core/src/providers/lua_provider.rs

  • Move: crates/owlry/src/plugins/crates/owlry-core/src/plugins/

  • Move: crates/owlry/src/notify.rscrates/owlry-core/src/notify.rs

  • Move: crates/owlry/src/paths.rscrates/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-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)

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:

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/:

// 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 — add pub 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 — 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:

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 — 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:

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() and refresh_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 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
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

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 — add ProfileConfig

  • 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 / -p flag
  • Add --profile flag: Option<String>
  • Keep --mode / -m flag
  • Repurpose -p as 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 --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
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-luaowlry-plugins/crates/owlry-lua

  • Move: owlry/crates/owlry-runeowlry-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-luaowlry-plugins/aur/

  • Move: owlry/aur/owlry-runeowlry-plugins/aur/

  • Move: owlry/docs/PLUGIN_DEVELOPMENT.mdowlry-plugins/docs/

  • Move: owlry/docs/PLUGINS.mdowlry-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.