notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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::*;