Skip to main content

rustyclaw_core/canvas/
host.rs

1//! Canvas host server for serving content and handling A2UI.
2
3use anyhow::Result;
4use std::collections::HashMap;
5use std::path::PathBuf;
6use std::sync::Arc;
7use tokio::sync::RwLock;
8use tracing::{debug, info};
9
10use super::a2ui::{A2UIMessage, A2UISurface};
11use super::config::CanvasConfig;
12
13/// Canvas host server.
14///
15/// Serves HTML/CSS/JS content and handles A2UI updates.
16pub struct CanvasHost {
17    /// Configuration
18    config: CanvasConfig,
19
20    /// Workspace root
21    workspace: PathBuf,
22
23    /// Active A2UI surfaces by session
24    surfaces: Arc<RwLock<HashMap<String, HashMap<String, A2UISurface>>>>,
25
26    /// Whether the host is running
27    running: Arc<RwLock<bool>>,
28}
29
30impl CanvasHost {
31    /// Create a new canvas host.
32    pub fn new(config: CanvasConfig, workspace: PathBuf) -> Self {
33        Self {
34            config,
35            workspace,
36            surfaces: Arc::new(RwLock::new(HashMap::new())),
37            running: Arc::new(RwLock::new(false)),
38        }
39    }
40
41    /// Start the canvas host server.
42    pub async fn start(&self) -> Result<()> {
43        if !self.config.enabled {
44            info!("Canvas is disabled");
45            return Ok(());
46        }
47
48        // Ensure canvas root exists
49        let root = self.config.canvas_root(&self.workspace);
50        tokio::fs::create_dir_all(&root).await?;
51
52        *self.running.write().await = true;
53        info!(port = self.config.port, root = %root.display(), "Canvas host started");
54
55        // Note: Full HTTP server implementation would go here
56        // For now, this is a placeholder for the API surface
57
58        Ok(())
59    }
60
61    /// Stop the canvas host server.
62    pub async fn stop(&self) -> Result<()> {
63        *self.running.write().await = false;
64        info!("Canvas host stopped");
65        Ok(())
66    }
67
68    /// Check if the host is running.
69    pub async fn is_running(&self) -> bool {
70        *self.running.read().await
71    }
72
73    /// Get the canvas URL for a session.
74    pub fn canvas_url(&self, session: &str) -> String {
75        format!("http://localhost:{}/canvas/{}/", self.config.port, session)
76    }
77
78    /// Get the A2UI URL for a session.
79    pub fn a2ui_url(&self, session: &str) -> String {
80        format!(
81            "http://localhost:{}/__rustyclaw__/a2ui/{}/",
82            self.config.port, session
83        )
84    }
85
86    // ── Session management ──────────────────────────────────────────────────
87
88    /// Ensure a session canvas directory exists.
89    pub async fn ensure_session(&self, session: &str) -> Result<PathBuf> {
90        let dir = self.config.session_dir(&self.workspace, session);
91        tokio::fs::create_dir_all(&dir).await?;
92        Ok(dir)
93    }
94
95    /// Write a file to a session's canvas directory.
96    pub async fn write_file(&self, session: &str, path: &str, content: &[u8]) -> Result<PathBuf> {
97        let dir = self.ensure_session(session).await?;
98        let file_path = dir.join(path);
99
100        // Ensure parent directories exist
101        if let Some(parent) = file_path.parent() {
102            tokio::fs::create_dir_all(parent).await?;
103        }
104
105        tokio::fs::write(&file_path, content).await?;
106        debug!(session, path, "Canvas file written");
107
108        Ok(file_path)
109    }
110
111    /// Read a file from a session's canvas directory.
112    pub async fn read_file(&self, session: &str, path: &str) -> Result<Vec<u8>> {
113        let dir = self.config.session_dir(&self.workspace, session);
114        let file_path = dir.join(path);
115        let content = tokio::fs::read(&file_path).await?;
116        Ok(content)
117    }
118
119    // ── A2UI support ────────────────────────────────────────────────────────
120
121    /// Push A2UI messages to a session.
122    pub async fn push_a2ui(&self, session: &str, messages: Vec<A2UIMessage>) -> Result<()> {
123        let mut surfaces = self.surfaces.write().await;
124        let session_surfaces = surfaces.entry(session.to_string()).or_default();
125
126        for msg in messages {
127            match &msg {
128                A2UIMessage::BeginRendering { surface_id, .. }
129                | A2UIMessage::SurfaceUpdate { surface_id, .. }
130                | A2UIMessage::DataModelUpdate { surface_id, .. } => {
131                    let surface = session_surfaces
132                        .entry(surface_id.clone())
133                        .or_insert_with(|| A2UISurface::new(surface_id));
134                    surface.apply(&msg);
135                }
136                A2UIMessage::DeleteSurface { surface_id } => {
137                    session_surfaces.remove(surface_id);
138                }
139            }
140        }
141
142        debug!(
143            session,
144            count = session_surfaces.len(),
145            "A2UI surfaces updated"
146        );
147        Ok(())
148    }
149
150    /// Push simple text to A2UI.
151    pub async fn push_text(&self, session: &str, text: &str) -> Result<()> {
152        use super::a2ui::{A2UIChildren, A2UIComponent, A2UIComponentDef, A2UITextValue};
153
154        let messages = vec![
155            A2UIMessage::SurfaceUpdate {
156                surface_id: "main".to_string(),
157                components: vec![
158                    A2UIComponentDef {
159                        id: "root".to_string(),
160                        component: A2UIComponent::Column {
161                            children: A2UIChildren::ExplicitList(vec!["content".to_string()]),
162                            spacing: None,
163                        },
164                    },
165                    A2UIComponentDef {
166                        id: "content".to_string(),
167                        component: A2UIComponent::Text {
168                            text: A2UITextValue::literal(text),
169                            usage_hint: Some("body".to_string()),
170                        },
171                    },
172                ],
173            },
174            A2UIMessage::BeginRendering {
175                surface_id: "main".to_string(),
176                root: "root".to_string(),
177            },
178        ];
179
180        self.push_a2ui(session, messages).await
181    }
182
183    /// Get all surfaces for a session.
184    pub async fn get_surfaces(&self, session: &str) -> HashMap<String, A2UISurface> {
185        self.surfaces
186            .read()
187            .await
188            .get(session)
189            .cloned()
190            .unwrap_or_default()
191    }
192
193    /// Reset A2UI state for a session.
194    pub async fn reset_a2ui(&self, session: &str) -> Result<()> {
195        self.surfaces.write().await.remove(session);
196        debug!(session, "A2UI state reset");
197        Ok(())
198    }
199
200    // ── Snapshot ────────────────────────────────────────────────────────────
201
202    /// Capture a snapshot of the canvas (placeholder for browser-based capture).
203    pub async fn snapshot(&self, _session: &str) -> Result<Vec<u8>> {
204        // This would require browser automation to capture
205        // For now, return an error indicating it's not implemented
206        anyhow::bail!("Canvas snapshot requires browser automation (not yet implemented)")
207    }
208}