open_sesame/util/
ipc.rs

1//! Unix domain socket IPC for inter-instance communication
2//!
3//! Replaces signal-based IPC with a proper message protocol.
4//! Provides reliable, bidirectional communication between instances.
5
6use crate::util::paths;
7use std::io::{Read, Write};
8use std::os::unix::net::{UnixListener, UnixStream};
9use std::path::PathBuf;
10use std::sync::mpsc::{self, Receiver, Sender};
11use std::thread;
12use std::time::Duration;
13
14/// IPC commands that can be sent between instances
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum IpcCommand {
17    /// Cycle selection forward (Alt+Tab)
18    CycleForward,
19    /// Cycle selection backward (Alt+Shift+Tab)
20    CycleBackward,
21    /// Ping to check if instance is alive
22    Ping,
23}
24
25impl IpcCommand {
26    fn to_byte(self) -> u8 {
27        match self {
28            IpcCommand::CycleForward => b'F',
29            IpcCommand::CycleBackward => b'B',
30            IpcCommand::Ping => b'P',
31        }
32    }
33
34    fn from_byte(byte: u8) -> Option<Self> {
35        match byte {
36            b'F' => Some(IpcCommand::CycleForward),
37            b'B' => Some(IpcCommand::CycleBackward),
38            b'P' => Some(IpcCommand::Ping),
39            _ => None,
40        }
41    }
42}
43
44/// IPC responses
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum IpcResponse {
47    /// Command acknowledged and executed
48    Ok,
49    /// Pong response to ping
50    Pong,
51    /// Error occurred
52    Error,
53}
54
55impl IpcResponse {
56    fn to_byte(self) -> u8 {
57        match self {
58            IpcResponse::Ok => b'K',
59            IpcResponse::Pong => b'O',
60            IpcResponse::Error => b'E',
61        }
62    }
63
64    fn from_byte(byte: u8) -> Option<Self> {
65        match byte {
66            b'K' => Some(IpcResponse::Ok),
67            b'O' => Some(IpcResponse::Pong),
68            b'E' => Some(IpcResponse::Error),
69            _ => None,
70        }
71    }
72}
73
74/// IPC server that listens for commands from other instances
75///
76/// # Thread Lifecycle
77///
78/// The listener thread is spawned in `start()` and runs until process exit.
79/// There is no explicit shutdown mechanism because:
80/// - Application is short-lived (typically <1 second runtime)
81/// - Thread holds no critical resources
82/// - OS cleans up threads and file descriptors on process exit
83pub struct IpcServer {
84    receiver: Receiver<IpcCommand>,
85    _listener_thread: thread::JoinHandle<()>,
86    socket_path: PathBuf,
87}
88
89impl IpcServer {
90    /// Creates and starts the IPC server.
91    pub fn start() -> std::io::Result<Self> {
92        let socket_path = Self::socket_path();
93
94        // Stale socket file removed if exists
95        if socket_path.exists() {
96            std::fs::remove_file(&socket_path).ok();
97        }
98
99        // Parent directory existence ensured
100        if let Some(parent) = socket_path.parent() {
101            std::fs::create_dir_all(parent)?;
102        }
103
104        let listener = UnixListener::bind(&socket_path)?;
105        listener.set_nonblocking(true)?;
106
107        tracing::info!("IPC server listening on {:?}", socket_path);
108
109        let (sender, receiver) = mpsc::channel();
110        let path_clone = socket_path.clone();
111
112        let listener_thread = thread::spawn(move || {
113            Self::listener_loop(listener, sender, path_clone);
114        });
115
116        Ok(Self {
117            receiver,
118            _listener_thread: listener_thread,
119            socket_path,
120        })
121    }
122
123    /// Checks for pending IPC commands (non-blocking).
124    pub fn try_recv(&self) -> Option<IpcCommand> {
125        self.receiver.try_recv().ok()
126    }
127
128    /// Returns the socket path.
129    fn socket_path() -> PathBuf {
130        match paths::cache_dir() {
131            Ok(dir) => dir.join("ipc.sock"),
132            Err(_) => {
133                let uid = unsafe { libc::getuid() };
134                PathBuf::from(format!("/run/user/{}/open-sesame.sock", uid))
135            }
136        }
137    }
138
139    /// Listener thread main loop
140    ///
141    /// Note: This thread intentionally has no explicit shutdown mechanism.
142    /// Rationale:
143    /// 1. The application is short-lived (exits after window selection)
144    /// 2. Thread is I/O bound with short timeouts (no blocking operations)
145    /// 3. Thread holds no critical resources (socket cleanup is in Drop)
146    /// 4. OS automatically cleans up threads when process exits
147    ///
148    /// For a long-running daemon, you would add:
149    /// - AtomicBool shutdown flag
150    /// - Check flag in loop
151    /// - Signal shutdown from Drop impl
152    ///
153    /// But for this use case, it's unnecessary complexity.
154    fn listener_loop(listener: UnixListener, sender: Sender<IpcCommand>, _path: PathBuf) {
155        loop {
156            match listener.accept() {
157                Ok((mut stream, _)) => {
158                    // Read timeout configuration
159                    stream
160                        .set_read_timeout(Some(Duration::from_millis(100)))
161                        .ok();
162
163                    let mut buf = [0u8; 1];
164                    if stream.read_exact(&mut buf).is_ok()
165                        && let Some(cmd) = IpcCommand::from_byte(buf[0])
166                    {
167                        tracing::debug!("IPC received command: {:?}", cmd);
168
169                        // Response generation and transmission
170                        let response = if cmd == IpcCommand::Ping {
171                            IpcResponse::Pong
172                        } else {
173                            // Command forwarded to main thread
174                            if sender.send(cmd).is_ok() {
175                                IpcResponse::Ok
176                            } else {
177                                IpcResponse::Error
178                            }
179                        };
180
181                        stream.write_all(&[response.to_byte()]).ok();
182                    }
183                }
184                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
185                    // No pending connection, brief sleep
186                    thread::sleep(Duration::from_millis(10));
187                }
188                Err(e) => {
189                    tracing::error!("IPC accept error: {}", e);
190                    thread::sleep(Duration::from_millis(100));
191                }
192            }
193        }
194    }
195}
196
197impl Drop for IpcServer {
198    fn drop(&mut self) {
199        // Socket file cleanup
200        std::fs::remove_file(&self.socket_path).ok();
201    }
202}
203
204/// IPC client for sending commands to a running instance
205pub struct IpcClient;
206
207impl IpcClient {
208    /// Sends a command to the running instance.
209    pub fn send(cmd: IpcCommand) -> std::io::Result<IpcResponse> {
210        let socket_path = IpcServer::socket_path();
211
212        let mut stream = UnixStream::connect(&socket_path)?;
213        stream.set_read_timeout(Some(Duration::from_millis(500)))?;
214        stream.set_write_timeout(Some(Duration::from_millis(500)))?;
215
216        // Command transmission
217        stream.write_all(&[cmd.to_byte()])?;
218
219        // Response reception
220        let mut buf = [0u8; 1];
221        stream.read_exact(&mut buf)?;
222
223        IpcResponse::from_byte(buf[0]).ok_or_else(|| {
224            std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid IPC response")
225        })
226    }
227
228    /// Returns whether another instance is running.
229    pub fn is_instance_running() -> bool {
230        Self::send(IpcCommand::Ping).is_ok()
231    }
232
233    /// Sends cycle forward command.
234    pub fn signal_cycle_forward() -> bool {
235        match Self::send(IpcCommand::CycleForward) {
236            Ok(IpcResponse::Ok) => {
237                tracing::info!("IPC: cycle forward acknowledged");
238                true
239            }
240            Ok(resp) => {
241                tracing::warn!("IPC: unexpected response {:?}", resp);
242                false
243            }
244            Err(e) => {
245                tracing::error!("IPC: failed to send cycle forward: {}", e);
246                false
247            }
248        }
249    }
250
251    /// Sends cycle backward command.
252    pub fn signal_cycle_backward() -> bool {
253        match Self::send(IpcCommand::CycleBackward) {
254            Ok(IpcResponse::Ok) => {
255                tracing::info!("IPC: cycle backward acknowledged");
256                true
257            }
258            Ok(resp) => {
259                tracing::warn!("IPC: unexpected response {:?}", resp);
260                false
261            }
262            Err(e) => {
263                tracing::error!("IPC: failed to send cycle backward: {}", e);
264                false
265            }
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_command_byte_roundtrip() {
276        for cmd in [
277            IpcCommand::CycleForward,
278            IpcCommand::CycleBackward,
279            IpcCommand::Ping,
280        ] {
281            let byte = cmd.to_byte();
282            let decoded = IpcCommand::from_byte(byte);
283            assert_eq!(decoded, Some(cmd));
284        }
285    }
286
287    #[test]
288    fn test_response_byte_roundtrip() {
289        for resp in [IpcResponse::Ok, IpcResponse::Pong, IpcResponse::Error] {
290            let byte = resp.to_byte();
291            let decoded = IpcResponse::from_byte(byte);
292            assert_eq!(decoded, Some(resp));
293        }
294    }
295
296    #[test]
297    fn test_invalid_bytes() {
298        assert_eq!(IpcCommand::from_byte(0), None);
299        assert_eq!(IpcCommand::from_byte(255), None);
300        assert_eq!(IpcResponse::from_byte(0), None);
301        assert_eq!(IpcResponse::from_byte(255), None);
302    }
303}