ipc.rs (8121B)
1 //! IPC module for external spawn-agent commands via Unix domain sockets. 2 //! 3 //! This allows external tools (like `notedeck-spawn`) to create new agent 4 //! sessions in a running notedeck instance. 5 6 use serde::{Deserialize, Serialize}; 7 use std::path::PathBuf; 8 9 /// Request to spawn a new agent 10 #[derive(Debug, Serialize, Deserialize)] 11 pub struct SpawnRequest { 12 #[serde(rename = "type")] 13 pub request_type: String, 14 pub cwd: PathBuf, 15 } 16 17 /// Response to a spawn request 18 #[derive(Debug, Serialize, Deserialize)] 19 pub struct SpawnResponse { 20 pub status: String, 21 #[serde(skip_serializing_if = "Option::is_none")] 22 pub session_id: Option<u32>, 23 #[serde(skip_serializing_if = "Option::is_none")] 24 pub message: Option<String>, 25 } 26 27 impl SpawnResponse { 28 pub fn ok(session_id: u32) -> Self { 29 Self { 30 status: "ok".to_string(), 31 session_id: Some(session_id), 32 message: None, 33 } 34 } 35 36 pub fn error(message: impl Into<String>) -> Self { 37 Self { 38 status: "error".to_string(), 39 session_id: None, 40 message: Some(message.into()), 41 } 42 } 43 } 44 45 /// Returns the path to the IPC socket. 46 /// 47 /// Uses XDG_RUNTIME_DIR on Linux (e.g., /run/user/1000/notedeck/spawn.sock) 48 /// or falls back to a user-local directory. 49 pub fn socket_path() -> PathBuf { 50 // Try XDG_RUNTIME_DIR first (Linux) 51 if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { 52 return PathBuf::from(runtime_dir) 53 .join("notedeck") 54 .join("spawn.sock"); 55 } 56 57 // macOS: use Application Support 58 #[cfg(target_os = "macos")] 59 if let Some(home) = dirs::home_dir() { 60 return home 61 .join("Library") 62 .join("Application Support") 63 .join("notedeck") 64 .join("spawn.sock"); 65 } 66 67 // Fallback: ~/.local/share/notedeck 68 dirs::data_local_dir() 69 .unwrap_or_else(|| PathBuf::from(".")) 70 .join("notedeck") 71 .join("spawn.sock") 72 } 73 74 #[cfg(unix)] 75 pub use unix::*; 76 77 #[cfg(unix)] 78 mod unix { 79 use super::*; 80 use std::io::{BufRead, BufReader, Write}; 81 use std::os::unix::net::{UnixListener, UnixStream}; 82 use std::sync::mpsc; 83 use std::thread; 84 85 /// A pending IPC connection that needs to be processed 86 pub struct PendingConnection { 87 pub stream: UnixStream, 88 pub cwd: PathBuf, 89 } 90 91 /// Handle to the IPC listener background thread 92 pub struct IpcListener { 93 receiver: mpsc::Receiver<PendingConnection>, 94 } 95 96 impl IpcListener { 97 /// Poll for pending connections (non-blocking) 98 pub fn try_recv(&self) -> Option<PendingConnection> { 99 self.receiver.try_recv().ok() 100 } 101 } 102 103 /// Creates an IPC listener that runs in a background thread. 104 /// 105 /// The background thread blocks on accept() and calls request_repaint() 106 /// when a connection arrives, ensuring the UI wakes up immediately. 107 /// 108 /// Returns None if the socket cannot be created (e.g., permission issues). 109 pub fn create_listener(ctx: egui::Context) -> Option<IpcListener> { 110 let path = socket_path(); 111 112 // Ensure parent directory exists 113 if let Some(parent) = path.parent() { 114 if let Err(e) = std::fs::create_dir_all(parent) { 115 tracing::warn!("Failed to create IPC socket directory: {}", e); 116 return None; 117 } 118 } 119 120 // Remove stale socket if it exists 121 if path.exists() { 122 if let Err(e) = std::fs::remove_file(&path) { 123 tracing::warn!("Failed to remove stale socket: {}", e); 124 return None; 125 } 126 } 127 128 // Create and bind the listener (blocking mode for the background thread) 129 let listener = match UnixListener::bind(&path) { 130 Ok(listener) => { 131 tracing::info!("IPC listener started at {}", path.display()); 132 listener 133 } 134 Err(e) => { 135 tracing::warn!("Failed to create IPC listener: {}", e); 136 return None; 137 } 138 }; 139 140 // Channel for sending connections to the main thread 141 let (sender, receiver) = mpsc::channel(); 142 143 // Spawn background thread to handle incoming connections 144 thread::Builder::new() 145 .name("ipc-listener".to_string()) 146 .spawn(move || { 147 for stream in listener.incoming() { 148 match stream { 149 Ok(mut stream) => { 150 // Parse the request in the background thread 151 match handle_connection(&mut stream) { 152 Ok(cwd) => { 153 let pending = PendingConnection { stream, cwd }; 154 if sender.send(pending).is_err() { 155 // Main thread dropped the receiver, exit 156 tracing::debug!("IPC listener: main thread gone, exiting"); 157 break; 158 } 159 // Wake up the UI to process the connection 160 ctx.request_repaint(); 161 } 162 Err(e) => { 163 // Send error response directly 164 let response = SpawnResponse::error(&e); 165 let _ = send_response(&mut stream, &response); 166 tracing::warn!("IPC spawn-agent failed: {}", e); 167 } 168 } 169 } 170 Err(e) => { 171 tracing::warn!("IPC accept error: {}", e); 172 } 173 } 174 } 175 tracing::debug!("IPC listener thread exiting"); 176 }) 177 .ok()?; 178 179 Some(IpcListener { receiver }) 180 } 181 182 /// Handles a single IPC connection, returning the cwd if valid spawn request. 183 pub fn handle_connection(stream: &mut UnixStream) -> Result<PathBuf, String> { 184 // Read the request line 185 let mut reader = BufReader::new(stream.try_clone().map_err(|e| e.to_string())?); 186 let mut line = String::new(); 187 reader.read_line(&mut line).map_err(|e| e.to_string())?; 188 189 // Parse JSON request 190 let request: SpawnRequest = 191 serde_json::from_str(&line).map_err(|e| format!("Invalid JSON: {}", e))?; 192 193 // Validate request type 194 if request.request_type != "spawn_agent" { 195 return Err(format!("Unknown request type: {}", request.request_type)); 196 } 197 198 // Validate path exists and is a directory 199 if !request.cwd.exists() { 200 return Err(format!("Path does not exist: {}", request.cwd.display())); 201 } 202 if !request.cwd.is_dir() { 203 return Err(format!( 204 "Path is not a directory: {}", 205 request.cwd.display() 206 )); 207 } 208 209 Ok(request.cwd) 210 } 211 212 /// Sends a response back to the client 213 pub fn send_response(stream: &mut UnixStream, response: &SpawnResponse) -> std::io::Result<()> { 214 let json = serde_json::to_string(response)?; 215 writeln!(stream, "{}", json)?; 216 stream.flush() 217 } 218 } 219 220 // Stub for non-Unix platforms (Windows) 221 #[cfg(not(unix))] 222 pub mod non_unix { 223 use std::path::PathBuf; 224 225 /// Stub for PendingConnection on non-Unix platforms 226 pub struct PendingConnection { 227 pub cwd: PathBuf, 228 } 229 230 /// Stub for IpcListener on non-Unix platforms 231 pub struct IpcListener; 232 233 impl IpcListener { 234 pub fn try_recv(&self) -> Option<PendingConnection> { 235 None 236 } 237 } 238 239 pub fn create_listener(_ctx: egui::Context) -> Option<IpcListener> { 240 tracing::info!("IPC spawn-agent not supported on this platform"); 241 None 242 } 243 } 244 245 #[cfg(not(unix))] 246 pub use non_unix::*;