Risuko
Architecture

Trait System

How Risuko decouples the engine from runtime environments using Rust traits.

The engine uses three traits (defined in risuko-engine/src/traits.rs) to abstract away environment-specific behavior. The desktop app provides Tauri-backed implementations in src-tauri/src/bridge.rs; the CLI and Node.js bindings bypass the traits and drive TaskManager and EventBroadcaster directly.

ConfigDirProvider

Controls where configuration and data files are stored.

pub trait ConfigDirProvider: Send + Sync {
    fn config_dir(&self) -> PathBuf;
}

The consumer is ConfigManager::new(&dyn ConfigDirProvider). ConfigManager::with_dir(PathBuf) also exists for an explicit path.

Implementations

ImplementationUsed ByBehavior
TauriConfigDirDesktop App (src/bridge.rs)AppHandle::path().app_config_dir(), resolved once at construction

Default directory (CLI, NAPI)

The CLI and Node.js bindings do not implement the trait. Each resolves the directory with a plain helper and loads system.json / user.json from it directly:

fn get_config_dir() -> PathBuf {
    dirs::config_dir()
        .map(|d| d.join("dev.risuko.app"))
        .unwrap_or_else(|| PathBuf::from("."))
}

Resolves to:

  • macOS: ~/Library/Application Support/dev.risuko.app
  • Linux: ~/.config/dev.risuko.app
  • Windows: C:\Users\<user>\AppData\Roaming\dev.risuko.app

EventSink

Receives engine events and forwards them to the host environment.

pub trait EventSink: Send + Sync {
    fn emit(&self, event: &str, payload: Value);
}

Implementations

ImplementationUsed ByBehavior
NoopEventSinkDesktop app bootstrap (AppState::new builds RssManager / UploadSinkManager with a placeholder sink; it takes only a StorageBackend, not an event sink), engine testsDiscards events silently
TauriEventSinkDesktop App (src/bridge.rs)Calls AppHandle::emit() to webview

After AppState is built, the desktop app swaps the upload manager's bootstrap sink for the live TauriEventSink via UploadSinkManager::set_event_sink. The Node.js bindings and the standalone CLI use the EventBroadcaster instead of constructing an EventSink: the NAPI on_event calls events.subscribe() and forwards each EngineEvent to a JS callback through a ThreadsafeFunction, while the CLI hands the broadcaster to its RpcServer, whose WebSocket handler subscribes and relays events to RPC clients.

NoopEventSink

pub struct NoopEventSink;

impl EventSink for NoopEventSink {
    fn emit(&self, _event: &str, _payload: Value) {}
}

StorageBackend

Persistent key-value storage for RSS feeds, upload-sink configuration, and other state.

pub trait StorageBackend: Send + Sync {
    fn load(&self, key: &str) -> Result<Option<Value>, String>;
    fn save(&self, key: &str, value: &Value) -> Result<(), String>;
}

Implementations

ImplementationUsed ByBehavior
FileStorageEngine tests; available to embedders via FileStorage::new(dir)Pretty-printed {key}.json files in a directory
TauriStorageDesktop App (src/bridge.rs)Wraps tauri_plugin_store; each key is a store file with the value under its "data" entry

The CLI and Node.js bindings do not construct RssManager or UploadSinkManager, so they never use a StorageBackend.

FileStorage

pub struct FileStorage {
    dir: PathBuf,
}

impl StorageBackend for FileStorage {
    fn load(&self, key: &str) -> Result<Option<Value>, String> {
        let path = self.dir.join(format!("{key}.json"));
        if !path.exists() {
            return Ok(None);
        }
        let data = std::fs::read_to_string(&path)
            .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
        let value: Value = serde_json::from_str(&data)
            .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
        Ok(Some(value))
    }

    fn save(&self, key: &str, value: &Value) -> Result<(), String> {
        std::fs::create_dir_all(&self.dir)
            .map_err(|e| format!("Failed to create dir {}: {e}", self.dir.display()))?;
        let path = self.dir.join(format!("{key}.json"));
        let data =
            serde_json::to_string_pretty(value).map_err(|e| format!("Failed to serialize: {e}"))?;
        std::fs::write(&path, data)
            .map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
        Ok(())
    }
}

How the Engine Uses Traits

Both RssManager and UploadSinkManager are constructed from dyn trait objects today; ConfigManager takes a &dyn ConfigDirProvider; TaskManager takes a concrete EventBroadcaster and a &Path for the config directory:

// Desktop app setup (simplified, src-tauri/src/lib.rs)
let config_dir_provider = bridge::TauriConfigDir::new(handle);
let event_sink: Arc<dyn EventSink> = Arc::new(bridge::TauriEventSink::new(handle));
let storage: Arc<dyn StorageBackend> = Arc::new(bridge::TauriStorage::new(handle));

let config = ConfigManager::new(&config_dir_provider)?;
let rss = RssManager::new(storage.clone(), event_sink.clone());
let upload_sinks = UploadSinkManager::new(storage, event_sink.clone());

start_engine(&config, event_sink, Some(upload_sinks)).await?;

Inside start_engine (engine/mod.rs), an event bridge subscribes to the EventBroadcaster and forwards each EngineEvent to the EventSink as engine:* events. Consumers that bypass start_engine wire the EventBroadcaster themselves — NAPI subscribes via events.subscribe() and forwards each EngineEvent to a JS callback, while the CLI passes the broadcaster to its RpcServer, which subscribes and relays events to RPC clients; the trait surface only matters for ConfigManager, RssManager, UploadSinkManager, and for start_engine in engine/mod.rs.

Custom Implementations

You can implement these traits to integrate Risuko into other environments:

use risuko_engine::{ConfigDirProvider, EventSink, StorageBackend};

struct MyConfigDir;
impl ConfigDirProvider for MyConfigDir {
    fn config_dir(&self) -> PathBuf {
        PathBuf::from("/my/app/config")
    }
}

struct MyEventSink { /* your state */ }
impl EventSink for MyEventSink {
    fn emit(&self, event: &str, payload: Value) {
        // Send to your event system
    }
}

On this page