rsclaw-runtime 2026.6.26

rsclaw composition root: AppState/RPC handlers (a2a, cmd, cron, gateway, hooks, server, ws) + process entry point
//! Desktop channel — delivers messages to connected WebSocket clients (Tauri
//! UI).
//!
//! This channel does not receive inbound messages; it only supports outbound
//! delivery (used by cron jobs configured with `delivery.channel = "desktop"`).
//! Messages are broadcast as `"notification"` events to all active WS
//! connections.

use std::sync::Arc;

use anyhow::Result;
use futures::future::BoxFuture;
use tracing::debug;

use rsclaw_channel::Channel;
use rsclaw_types::OutboundMessage;
use crate::ws::{ConnRegistry, types::EventFrame};

/// A channel that delivers outbound messages to the desktop UI via WebSocket
/// broadcast.
pub struct DesktopChannel {
    conns: Arc<ConnRegistry>,
}

impl DesktopChannel {
    /// Create a new desktop channel backed by the given WebSocket connection
    /// registry.
    pub fn new(conns: Arc<ConnRegistry>) -> Self {
        Self { conns }
    }
}

impl Channel for DesktopChannel {
    fn name(&self) -> &str {
        "desktop"
    }

    fn send(&self, msg: OutboundMessage) -> BoxFuture<'_, Result<()>> {
        Box::pin(async move {
            // Detect optional kind marker. Other channel impls don't see this
            // because routing only sends desktop-targeted messages here. The
            // marker is a structural prefix the sender embeds when the
            // notification represents a non-conversational event (async
            // /task completion, async /send delivery) so the UI can badge
            // it visually instead of confusing the user with a duplicate
            // chat-style bubble.
            const KIND_PREFIX: &str = "\u{e000}rsclaw:kind=";
            const KIND_PAYLOAD_SEP: char = '\u{e001}';
            let (kind, body) = if let Some(rest) = msg.text.strip_prefix(KIND_PREFIX) {
                if let Some(sep_idx) = rest.find(KIND_PAYLOAD_SEP) {
                    let (k, b) = rest.split_at(sep_idx);
                    (
                        Some(k.to_owned()),
                        b[KIND_PAYLOAD_SEP.len_utf8()..].to_owned(),
                    )
                } else {
                    (None, msg.text.clone())
                }
            } else {
                (None, msg.text.clone())
            };

            let mut payload = serde_json::json!({
                "type": "notification",
                "channel": "desktop",
                "to": msg.target_id,
                "text": body,
            });
            if let Some(k) = kind.as_ref() {
                if let Some(obj) = payload.as_object_mut() {
                    obj.insert("kind".to_string(), serde_json::Value::String(k.clone()));
                }
            }
            // Forward any attachments (e.g. the astock `chart` tool pushes its
            // K-line PNG as a data-URI via host::notify_with_image). Without
            // this the image was silently dropped here — text reached the UI
            // but the chart never did, while channels like Feishu (which carry
            // OutboundMessage.images) showed it fine.
            if !msg.images.is_empty() {
                if let Some(obj) = payload.as_object_mut() {
                    obj.insert("images".to_string(), serde_json::json!(msg.images));
                }
            }
            if !msg.files.is_empty() {
                if let Some(obj) = payload.as_object_mut() {
                    obj.insert("files".to_string(), serde_json::json!(msg.files));
                }
            }
            let frame = EventFrame::new("notification", payload, 0);
            debug!(
                target_id = %msg.target_id,
                text_len = msg.text.len(),
                image_count = msg.images.len(),
                kind = ?kind,
                "desktop: broadcasting notification"
            );
            self.conns.broadcast_all(frame).await;
            Ok(())
        })
    }

    fn run(self: Arc<Self>) -> BoxFuture<'static, Result<()>> {
        // Desktop channel is send-only; no inbound loop needed.
        Box::pin(async { Ok(()) })
    }
}